diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5a51bf8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + args: --timeout=5m + + test: + name: Test + runs-on: ubuntu-latest + needs: lint # Only run tests if lint passes + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + files: ./coverage.out + fail_ci_if_error: false + continue-on-error: true + + build: + name: Build + runs-on: ubuntu-latest + needs: test # Only build if tests pass + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + exclude: + - goos: windows + goarch: arm64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + binary_name="dev-cli" + if [ "$GOOS" = "windows" ]; then + binary_name="${binary_name}.exe" + fi + go build -o "dist/${GOOS}-${GOARCH}/${binary_name}" . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: dev-cli-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2a30dc3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..4d93ae7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,76 @@ +# golangci-lint configuration for dev-cli +# Focused on Docker API safety and common Go bugs +# Run: golangci-lint run ./... + +run: + timeout: 5m + modules-download-mode: readonly + +linters: + enable: + # Critical for Docker SDK - catch unhandled errors + - errcheck + # Static analysis for common bugs + - staticcheck + # Lightweight linter, catches common mistakes + - revive + # Detect ineffectual assignments + - ineffassign + # Check for unchecked type assertions + - unconvert + # Detect unused code + - unused + # Check for goroutine leaks (important for streaming logs) + - govet + # Check printf-style functions + - goprintffuncname + + disable: + # Too noisy for dev tools + - gocritic + - gocyclo + - funlen + - gocognit + +linters-settings: + errcheck: + # Check for ignored errors in defer statements + check-blank: true + # Don't report on explicitly ignored errors with _ + exclude-functions: + - io.Copy + - (io.Closer).Close + + revive: + rules: + - name: blank-imports + - name: context-as-argument + - name: context-keys-type + - name: error-return + - name: error-strings + - name: exported + - name: increment-decrement + - name: var-declaration + - name: package-comments + disabled: true # Not needed for internal packages + + staticcheck: + checks: + - all + - -SA1019 # Ignore deprecation warnings for now + +issues: + # Maximum issues count per one linter + max-issues-per-linter: 50 + max-same-issues: 10 + + exclude-rules: + # Exclude some linters from running on test files + - path: _test\.go + linters: + - errcheck + + # Exclude lll issues for long lines in go.mod + - path: go\.mod + linters: + - lll diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..7311537 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,68 @@ +# GoReleaser configuration for dev-cli +# Triggered by: git tag v0.1.0 && git push origin v0.1.0 + +version: 2 + +project_name: dev-cli + +before: + hooks: + - go mod tidy + +builds: + - id: dev-cli + main: ./main.go + binary: dev-cli + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.ShortCommit}} + - -X main.date={{.Date}} + +archives: + - id: default + format: tar.gz + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + format: zip + files: + - README.md + - LICENSE + +checksum: + name_template: 'checksums.txt' + +snapshot: + version_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^ci:' + - '^chore:' + - Merge pull request + - Merge branch + +release: + github: + owner: opx0 # GitHub username/org + name: devops-agent-cli + draft: false + prerelease: auto + name_template: "v{{ .Version }}" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..659da90 --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +# dev-cli Makefile +# Development tasks for linting, testing, and building + +.PHONY: all build test lint clean install + +# Default target +all: lint test build + +# Build the CLI binary +build: + go build -o dev-cli . + +# Run tests with race detection (important for sync.RWMutex in Registry) +test: + go test -v -race ./... + +# Run short tests (skip integration tests that need Docker) +test-short: + go test -v -short ./... + +# Run linter +lint: + golangci-lint run ./... + +# Install golangci-lint if not present +install-lint: + @which golangci-lint > /dev/null || \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin + +# Clean build artifacts +clean: + rm -f dev-cli + go clean + +# Install the CLI locally +install: build + cp dev-cli $(GOPATH)/bin/ 2>/dev/null || cp dev-cli ~/go/bin/ + +# Run integration tests only (requires Docker) +test-integration: + go test -v -race -run Integration ./... + +# Check for issues without fixing +check: lint test-short + +# Tidy dependencies +tidy: + go mod tidy diff --git a/README.md b/README.md index 07af66c..59c7177 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ sqlite3 ~/.devlogs/history.db "SELECT command, exit_code FROM history WHERE exit ## Configuration -The CLI is configured via `~/.dev-cli/config.yaml` or Environment Variables. +The CLI is configured via `~/.devlogs/config.yaml` or Environment Variables. | Variable | Description | Default | | -------------------------- | ------------------ | --------------------------- | diff --git a/cmd/doctor.go b/cmd/doctor.go new file mode 100644 index 0000000..7928ef2 --- /dev/null +++ b/cmd/doctor.go @@ -0,0 +1,457 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var ( + doctorFix bool + doctorQuiet bool +) + +type CheckResult struct { + Name string + Status string // "ok", "warn", "fail" + Message string + FixCmd string + FixFunc func() error +} + +var doctorCmd = &cobra.Command{ + Use: "doctor", + Short: "Check system health and fix issues", + Long: `Run health checks on all dev-cli dependencies and optionally fix issues. + +Checks: + - Docker daemon status + - Ollama availability + - GPU/CUDA support + - Required directories + - Network connectivity`, + Example: ` # Run health checks + dev-cli doctor + + # Auto-fix issues where possible + dev-cli doctor --fix`, + Run: runDoctor, +} + +func init() { + rootCmd.AddCommand(doctorCmd) + doctorCmd.Flags().BoolVar(&doctorFix, "fix", false, "Attempt to auto-fix issues") + doctorCmd.Flags().BoolVar(&doctorQuiet, "quiet", false, "Only show failures") +} + +func runDoctor(cmd *cobra.Command, args []string) { + fmt.Println("\033[1m🔍 dev-cli doctor\033[0m") + fmt.Println() + + checks := []func() CheckResult{ + checkDocker, + checkDockerCompose, + checkOllama, + checkOllamaModel, + checkGPU, + checkDevlogsDir, + checkNetwork, + } + + var failed, warned, passed int + + for _, check := range checks { + result := check() + + if doctorQuiet && result.Status == "ok" { + passed++ + continue + } + + icon := getStatusIcon(result.Status) + fmt.Printf("%s \033[1m%s\033[0m\n", icon, result.Name) + fmt.Printf(" %s\n", result.Message) + + switch result.Status { + case "ok": + passed++ + case "warn": + warned++ + case "fail": + failed++ + if doctorFix && (result.FixCmd != "" || result.FixFunc != nil) { + fmt.Printf(" \033[33m➜ Attempting fix...\033[0m\n") + if err := attemptFix(result); err != nil { + fmt.Printf(" \033[31m✗ Fix failed: %v\033[0m\n", err) + } else { + fmt.Printf(" \033[32m✓ Fixed!\033[0m\n") + failed-- + passed++ + } + } else if result.FixCmd != "" { + fmt.Printf(" \033[36m💡 Fix: %s\033[0m\n", result.FixCmd) + } + } + fmt.Println() + } + + // Summary + fmt.Println("\033[90m────────────────────────────────\033[0m") + fmt.Printf("✓ %d passed ", passed) + if warned > 0 { + fmt.Printf("⚠ %d warnings ", warned) + } + if failed > 0 { + fmt.Printf("\033[31m✗ %d failed\033[0m", failed) + } + fmt.Println() + + if failed > 0 && !doctorFix { + fmt.Println("\n\033[33mRun 'dev-cli doctor --fix' to attempt auto-fixes\033[0m") + os.Exit(1) + } +} + +func getStatusIcon(status string) string { + switch status { + case "ok": + return "\033[32m✓\033[0m" + case "warn": + return "\033[33m⚠\033[0m" + case "fail": + return "\033[31m✗\033[0m" + default: + return "?" + } +} + +func attemptFix(result CheckResult) error { + if result.FixFunc != nil { + return result.FixFunc() + } + if result.FixCmd != "" { + cmd := exec.Command("sh", "-c", result.FixCmd) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + return fmt.Errorf("no fix available") +} + +func checkDocker() CheckResult { + result := CheckResult{Name: "Docker"} + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "docker", "info") + output, err := cmd.CombinedOutput() + + if err != nil { + if strings.Contains(string(output), "permission denied") { + return CheckResult{ + Name: "Docker", + Status: "fail", + Message: "Permission denied - user not in docker group", + FixCmd: "sudo usermod -aG docker $USER && newgrp docker", + } + } + if strings.Contains(string(output), "Cannot connect") || strings.Contains(err.Error(), "executable file not found") { + return CheckResult{ + Name: "Docker", + Status: "fail", + Message: "Docker daemon not running", + FixCmd: "sudo systemctl start docker", + } + } + return CheckResult{ + Name: "Docker", + Status: "fail", + Message: fmt.Sprintf("Docker check failed: %v", err), + } + } + + // Get version + versionCmd := exec.Command("docker", "--version") + versionOutput, _ := versionCmd.Output() + version := strings.TrimSpace(string(versionOutput)) + + result.Status = "ok" + result.Message = version + return result +} + +func checkDockerCompose() CheckResult { + result := CheckResult{Name: "Docker Compose"} + + // Check docker compose (plugin) + if err := exec.Command("docker", "compose", "version").Run(); err == nil { + cmd := exec.Command("docker", "compose", "version", "--short") + output, _ := cmd.Output() + result.Status = "ok" + result.Message = "Plugin v" + strings.TrimSpace(string(output)) + return result + } + + // Check docker-compose (standalone) + if _, err := exec.LookPath("docker-compose"); err == nil { + cmd := exec.Command("docker-compose", "--version") + output, _ := cmd.Output() + result.Status = "ok" + result.Message = strings.TrimSpace(string(output)) + return result + } + + // Neither available + return CheckResult{ + Name: "Docker Compose", + Status: "fail", + Message: "Docker Compose not installed", + FixCmd: "sudo pacman -S docker-compose", + FixFunc: func() error { + // Try to install docker-compose via pacman (Arch) + cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", "docker-compose") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }, + } +} + +func checkOllama() CheckResult { + result := CheckResult{Name: "Ollama"} + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://localhost:11434/api/tags") + + if err == nil && resp != nil { + defer resp.Body.Close() + if resp.StatusCode == 200 { + result.Status = "ok" + result.Message = "Running on localhost:11434" + return result + } + } + + // Ollama not responding - check if Docker container exists + dockerCheck := exec.Command("docker", "ps", "-a", "--filter", "name=ollama", "--format", "{{.Names}}") + output, _ := dockerCheck.Output() + containerExists := strings.TrimSpace(string(output)) != "" + + if containerExists { + // Container exists but not responding - try starting it + return CheckResult{ + Name: "Ollama", + Status: "fail", + Message: "Ollama container exists but not responding", + FixCmd: "docker start ollama", + FixFunc: func() error { + cmd := exec.Command("docker", "start", "ollama") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }, + } + } + + // Check if infra/ollama/docker-compose.yml exists + projectRoot := getProjectRoot() + composeFile := filepath.Join(projectRoot, "infra", "ollama", "docker-compose.yml") + if _, err := os.Stat(composeFile); err == nil { + return CheckResult{ + Name: "Ollama", + Status: "fail", + Message: "Ollama not running (Docker compose available)", + FixCmd: "cd infra/ollama && docker compose up -d", + FixFunc: func() error { + return runDockerCompose(composeFile, "up", "-d") + }, + } + } + + // Fallback: check if native ollama is installed + if _, err := exec.LookPath("ollama"); err == nil { + return CheckResult{ + Name: "Ollama", + Status: "fail", + Message: "Ollama installed but not running", + FixCmd: "ollama serve &", + } + } + + // Nothing available - suggest Docker setup + return CheckResult{ + Name: "Ollama", + Status: "fail", + Message: "Ollama not installed", + FixCmd: "cd infra/ollama && docker compose up -d", + FixFunc: func() error { + // Create infra/ollama if needed and start + if projectRoot != "" { + composeFile := filepath.Join(projectRoot, "infra", "ollama", "docker-compose.yml") + if _, err := os.Stat(composeFile); err == nil { + return runDockerCompose(composeFile, "up", "-d") + } + } + return fmt.Errorf("docker-compose.yml not found - run from project root") + }, + } +} + +func checkOllamaModel() CheckResult { + result := CheckResult{Name: "Ollama Model"} + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://localhost:11434/api/tags") + + if err != nil { + return CheckResult{ + Name: "Ollama Model", + Status: "warn", + Message: "Cannot check models - Ollama not running", + } + } + defer resp.Body.Close() + + // Check if any model is available + cmd := exec.Command("sh", "-c", "curl -s http://localhost:11434/api/tags | grep -o '\"name\":\"[^\"]*\"' | head -1") + output, _ := cmd.Output() + + if len(output) == 0 || !strings.Contains(string(output), "name") { + return CheckResult{ + Name: "Ollama Model", + Status: "warn", + Message: "No models installed", + FixCmd: "ollama pull llama3.2", + FixFunc: func() error { + cmd := exec.Command("ollama", "pull", "llama3.2") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }, + } + } + + result.Status = "ok" + result.Message = "Model(s) available" + return result +} + +func checkGPU() CheckResult { + result := CheckResult{Name: "GPU (NVIDIA)"} + + cmd := exec.Command("nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader") + output, err := cmd.Output() + + if err != nil { + return CheckResult{ + Name: "GPU (NVIDIA)", + Status: "warn", + Message: "NVIDIA GPU not detected (CPU mode will be used)", + } + } + + gpuInfo := strings.TrimSpace(string(output)) + result.Status = "ok" + result.Message = gpuInfo + return result +} + +func checkDevlogsDir() CheckResult { + result := CheckResult{Name: "Devlogs Directory"} + + homeDir, err := os.UserHomeDir() + if err != nil { + return CheckResult{ + Name: "Devlogs Directory", + Status: "fail", + Message: "Cannot determine home directory", + } + } + + devlogsDir := filepath.Join(homeDir, ".devlogs") + + if _, err := os.Stat(devlogsDir); os.IsNotExist(err) { + return CheckResult{ + Name: "Devlogs Directory", + Status: "warn", + Message: fmt.Sprintf("%s does not exist", devlogsDir), + FixFunc: func() error { + return os.MkdirAll(devlogsDir, 0755) + }, + } + } + + result.Status = "ok" + result.Message = devlogsDir + return result +} + +func checkNetwork() CheckResult { + result := CheckResult{Name: "Network"} + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("https://api.perplexity.ai") + + if err != nil { + return CheckResult{ + Name: "Network", + Status: "warn", + Message: "Cannot reach external APIs (cloud AI features may not work)", + } + } + defer resp.Body.Close() + + result.Status = "ok" + result.Message = "External APIs reachable" + return result +} + +func getProjectRoot() string { + // Try to find project root by looking for go.mod + dir, _ := os.Getwd() + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "" +} + +// getDockerComposeCmd returns the correct docker compose command +// Returns ("docker-compose", []string{}) or ("docker", []string{"compose"}) +func getDockerComposeCmd() (string, []string) { + // Try docker compose (plugin) first + if err := exec.Command("docker", "compose", "version").Run(); err == nil { + return "docker", []string{"compose"} + } + // Fall back to docker-compose (standalone) + if _, err := exec.LookPath("docker-compose"); err == nil { + return "docker-compose", []string{} + } + // Default to docker compose + return "docker", []string{"compose"} +} + +// runDockerCompose runs docker compose with the given args +func runDockerCompose(composeFile string, args ...string) error { + bin, prefix := getDockerComposeCmd() + cmdArgs := append(prefix, "-f", composeFile) + cmdArgs = append(cmdArgs, args...) + cmd := exec.Command(bin, cmdArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/cmd/export.go b/cmd/export.go new file mode 100644 index 0000000..b2ab9d2 --- /dev/null +++ b/cmd/export.go @@ -0,0 +1,153 @@ +package cmd + +import ( + "bufio" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" +) + +var ( + exportDocker string + exportFile string + exportLines int + exportSave bool +) + +var exportCmd = &cobra.Command{ + Use: "export", + Short: "Export logs for OpenCode ingestion", + Long: `Export container or file logs in a format suitable for OpenCode. +Output can be piped or saved to ~/.devlogs/last-error.md for use with OpenCode.`, + Example: ` # Export Docker container logs + dev-cli export --docker my-container --lines 50 + + # Export and save for OpenCode handoff + dev-cli export --docker my-container --save + + # Export from a log file + dev-cli export --file /var/log/app.log --lines 100`, + Run: runExport, +} + +func init() { + rootCmd.AddCommand(exportCmd) + exportCmd.Flags().StringVar(&exportDocker, "docker", "", "Docker container ID/name to export logs from") + exportCmd.Flags().StringVar(&exportFile, "file", "", "Log file path to export from") + exportCmd.Flags().IntVar(&exportLines, "lines", 50, "Number of log lines to export") + exportCmd.Flags().BoolVar(&exportSave, "save", false, "Save to ~/.devlogs/last-error.md for OpenCode handoff") +} + +func runExport(cmd *cobra.Command, args []string) { + if exportDocker == "" && exportFile == "" { + fmt.Fprintln(os.Stderr, "Error: must specify --docker or --file") + cmd.Usage() + os.Exit(1) + } + + var logs string + var source string + var err error + + if exportDocker != "" { + source = fmt.Sprintf("Docker container: %s", exportDocker) + logs, err = getDockerLogs(exportDocker, exportLines) + } else { + source = fmt.Sprintf("Log file: %s", exportFile) + logs, err = getFileLogs(exportFile, exportLines) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Format output for OpenCode + output := formatForOpenCode(source, logs) + + if exportSave { + savePath, err := saveForOpenCode(output) + if err != nil { + fmt.Fprintf(os.Stderr, "Error saving: %v\n", err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "\033[32mSaved to: %s\033[0m\n", savePath) + fmt.Fprintf(os.Stderr, "\033[36mRun 'opencode' and use: @%s\033[0m\n", savePath) + } else { + fmt.Print(output) + } +} + +func getDockerLogs(container string, lines int) (string, error) { + cmd := exec.Command("docker", "logs", "--tail", fmt.Sprintf("%d", lines), container) + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("docker logs failed: %w\n%s", err, string(output)) + } + return string(output), nil +} + +func getFileLogs(filePath string, lines int) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("open file failed: %w", err) + } + defer file.Close() + + // Read all lines and get last N + var allLines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + allLines = append(allLines, scanner.Text()) + } + + if err := scanner.Err(); err != nil && err != io.EOF { + return "", fmt.Errorf("read file failed: %w", err) + } + + start := 0 + if len(allLines) > lines { + start = len(allLines) - lines + } + + return strings.Join(allLines[start:], "\n"), nil +} + +func formatForOpenCode(source string, logs string) string { + var sb strings.Builder + sb.WriteString("# Error Context from dev-cli\n\n") + sb.WriteString(fmt.Sprintf("**Source:** %s\n", source)) + sb.WriteString(fmt.Sprintf("**Exported:** %s\n\n", time.Now().Format(time.RFC3339))) + sb.WriteString("## Logs\n\n") + sb.WriteString("```\n") + sb.WriteString(logs) + sb.WriteString("\n```\n\n") + sb.WriteString("## Instructions\n\n") + sb.WriteString("Analyze these logs, identify any errors or issues, and suggest fixes.\n") + return sb.String() +} + +func saveForOpenCode(content string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + devCliDir := filepath.Join(homeDir, ".devlogs") + if err := os.MkdirAll(devCliDir, 0755); err != nil { + return "", err + } + + savePath := filepath.Join(devCliDir, "last-error.md") + if err := os.WriteFile(savePath, []byte(content), 0644); err != nil { + return "", err + } + + return savePath, nil +} diff --git a/cmd/watch.go b/cmd/watch.go index 40e1f83..460806e 100644 --- a/cmd/watch.go +++ b/cmd/watch.go @@ -6,6 +6,7 @@ import ( "io" "os" "os/exec" + "path/filepath" "strings" "time" @@ -15,16 +16,19 @@ import ( ) var ( - watchDocker string - watchFile string - watchAI string + watchDocker string + watchFile string + watchAI string + watchOpenCode bool ) var watchCmd = &cobra.Command{ Use: "watch", Short: "Watch logs for errors and analyze them", Long: `Stream logs in real-time and get instant AI analysis when errors are detected. -Monitors for keywords like 'error', 'exception', 'panic', 'fatal', 'failed'.`, +Monitors for keywords like 'error', 'exception', 'panic', 'fatal', 'failed'. + +Use --opencode to save error context for OpenCode handoff instead of local analysis.`, Example: ` # Watch a log file dev-cli watch --file /var/log/syslog @@ -32,7 +36,10 @@ Monitors for keywords like 'error', 'exception', 'panic', 'fatal', 'failed'.`, dev-cli watch --docker my-container # Use cloud AI (Perplexity) for smarter analysis - dev-cli watch --docker db --ai cloud`, + dev-cli watch --docker db --ai cloud + + # Save errors for OpenCode handoff (no local AI) + dev-cli watch --docker db --opencode`, Run: runWatch, } @@ -41,6 +48,7 @@ func init() { watchCmd.Flags().StringVar(&watchDocker, "docker", "", "Docker container ID/name to monitor") watchCmd.Flags().StringVar(&watchFile, "file", "", "Log file path to monitor") watchCmd.Flags().StringVar(&watchAI, "ai", "local", "AI backend to use: 'local' (Ollama) or 'cloud' (Perplexity)") + watchCmd.Flags().BoolVar(&watchOpenCode, "opencode", false, "Save error context for OpenCode handoff instead of local analysis") } func runWatch(cmd *cobra.Command, args []string) { @@ -52,8 +60,10 @@ func runWatch(cmd *cobra.Command, args []string) { var logStream io.ReadCloser var err error + var source string if watchDocker != "" { + source = fmt.Sprintf("Docker container: %s", watchDocker) fmt.Printf("\033[36mWatching Docker container: %s\033[0m\n", watchDocker) c := exec.Command("docker", "logs", "-f", "--tail", "20", watchDocker) logStream, err = c.StdoutPipe() @@ -67,6 +77,7 @@ func runWatch(cmd *cobra.Command, args []string) { os.Exit(1) } } else { + source = fmt.Sprintf("Log file: %s", watchFile) fmt.Printf("\033[36mWatching file: %s\033[0m\n", watchFile) c := exec.Command("tail", "-f", "-n", "20", watchFile) logStream, err = c.StdoutPipe() @@ -88,9 +99,16 @@ func runWatch(cmd *cobra.Command, args []string) { lastAnalysis := time.Now().Add(-time.Hour) analysisCooldown := 10 * time.Second - client := llm.NewHybridClient() + var client *llm.HybridClient + if !watchOpenCode { + client = llm.NewHybridClient() + } - fmt.Println("\033[90mWaiting for logs... (Ctrl+C to exit)\033[0m") + if watchOpenCode { + fmt.Println("\033[90mOpenCode mode: errors will be saved for handoff (Ctrl+C to exit)\033[0m") + } else { + fmt.Println("\033[90mWaiting for logs... (Ctrl+C to exit)\033[0m") + } for scanner.Scan() { line := scanner.Text() @@ -111,20 +129,33 @@ func runWatch(cmd *cobra.Command, args []string) { } if isError && time.Since(lastAnalysis) > analysisCooldown { - fmt.Println("\n\033[33m[!] Error detected! Analyzing...\033[0m") - logContent := strings.Join(buffer, "\n") - result, err := client.AnalyzeLog(logContent, watchAI) - if err != nil { - fmt.Printf("\033[31mError analyzing log: %v\033[0m\n", err) - } else { - source := "Local" - if watchAI == "cloud" { - source = "Cloud" + + if watchOpenCode { + // OpenCode handoff mode + fmt.Println("\n\033[33m[!] Error detected! Saving for OpenCode...\033[0m") + savePath, err := saveErrorForOpenCode(source, logContent) + if err != nil { + fmt.Printf("\033[31mError saving context: %v\033[0m\n", err) + } else { + fmt.Printf("\033[32mSaved to: %s\033[0m\n", savePath) + fmt.Printf("\033[36mRun 'opencode' and use: @%s\033[0m\n", savePath) } - fmt.Printf("\033[90m> [%s AI]\033[0m \033[1m%s\033[0m\n", source, result.Explanation) - if result.Fix != "" { - fmt.Printf("\033[32mSuggested Fix: %s\033[0m\n", result.Fix) + } else { + // Traditional AI analysis mode + fmt.Println("\n\033[33m[!] Error detected! Analyzing...\033[0m") + result, err := client.AnalyzeLog(logContent, watchAI) + if err != nil { + fmt.Printf("\033[31mError analyzing log: %v\033[0m\n", err) + } else { + aiSource := "Local" + if watchAI == "cloud" { + aiSource = "Cloud" + } + fmt.Printf("\033[90m> [%s AI]\033[0m \033[1m%s\033[0m\n", aiSource, result.Explanation) + if result.Fix != "" { + fmt.Printf("\033[32mSuggested Fix: %s\033[0m\n", result.Fix) + } } } fmt.Println("\033[90m----------------------------------------\033[0m") @@ -132,3 +163,33 @@ func runWatch(cmd *cobra.Command, args []string) { } } } + +func saveErrorForOpenCode(source, logs string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + devCliDir := filepath.Join(homeDir, ".devlogs") + if err := os.MkdirAll(devCliDir, 0755); err != nil { + return "", err + } + + var sb strings.Builder + sb.WriteString("# Error Context from dev-cli watch\n\n") + sb.WriteString(fmt.Sprintf("**Source:** %s\n", source)) + sb.WriteString(fmt.Sprintf("**Detected:** %s\n\n", time.Now().Format(time.RFC3339))) + sb.WriteString("## Logs\n\n") + sb.WriteString("```\n") + sb.WriteString(logs) + sb.WriteString("\n```\n\n") + sb.WriteString("## Instructions\n\n") + sb.WriteString("Analyze these logs, identify the root cause of the error, and implement a fix.\n") + + savePath := filepath.Join(devCliDir, "last-error.md") + if err := os.WriteFile(savePath, []byte(sb.String()), 0644); err != nil { + return "", err + } + + return savePath, nil +} diff --git a/dev-cli b/dev-cli index eb00659..8204442 100755 Binary files a/dev-cli and b/dev-cli differ diff --git a/go.mod b/go.mod index a900884..0d2eba4 100644 --- a/go.mod +++ b/go.mod @@ -7,26 +7,51 @@ require ( github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/creack/pty v1.1.24 github.com/docker/docker v28.5.2+incompatible github.com/google/uuid v1.6.0 github.com/muesli/reflow v0.3.0 github.com/spf13/cobra v1.10.2 + github.com/testcontainers/testcontainers-go v0.40.0 modernc.org/sqlite v1.40.1 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect - github.com/creack/pty v1.1.24 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.4 // indirect github.com/fatih/color v1.7.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.2 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/crypto v0.43.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -47,7 +72,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -58,7 +83,6 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -83,5 +107,4 @@ require ( golang.org/x/term v0.38.0 golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.9.0 // indirect - gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index d2dfa1c..336c8f2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -6,8 +10,12 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -24,6 +32,8 @@ github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9G github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo= @@ -38,21 +48,29 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= @@ -64,6 +82,9 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -74,8 +95,20 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLW github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -88,10 +121,18 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= @@ -112,27 +153,46 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= @@ -154,30 +214,36 @@ go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4Etq go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= @@ -187,6 +253,9 @@ google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHh google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= diff --git a/infra/ollama/README.md b/infra/ollama/README.md new file mode 100644 index 0000000..8417ea1 --- /dev/null +++ b/infra/ollama/README.md @@ -0,0 +1,42 @@ +# Ollama for dev-cli + +Production-ready Ollama deployment for dev-cli's local AI features. + +## Quick Start + +```bash +cd infra/ollama +docker compose up -d +``` + +## Requirements + +- Docker with NVIDIA GPU support (nvidia-container-toolkit) +- NVIDIA GPU with CUDA support + +## CPU-Only Mode + +If you don't have an NVIDIA GPU, remove the `deploy` section from docker-compose.yml: + +```yaml +# Remove this section: +deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] +``` + +## Pull a Model + +```bash +docker exec -it ollama ollama pull llama3.2 +``` + +## Verify + +```bash +curl http://localhost:11434/api/tags +``` diff --git a/infra/ollama/docker-compose.yml b/infra/ollama/docker-compose.yml new file mode 100644 index 0000000..a50ed11 --- /dev/null +++ b/infra/ollama/docker-compose.yml @@ -0,0 +1,38 @@ +services: + ollama: + image: ollama/ollama:latest + container_name: ollama + restart: unless-stopped + tty: true + ports: + - "11434:11434" + environment: + - OLLAMA_KEEP_ALIVE=24h + - OLLAMA_HOST=0.0.0.0 + volumes: + - ollama_data:/root/.ollama + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 30s + timeout: 10s + retries: 3 + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + dns: + - 8.8.8.8 + - 1.1.1.1 + networks: + - dev-cli-net + +volumes: + ollama_data: + +networks: + dev-cli-net: + name: dev-cli-net + driver: bridge diff --git a/internal/infra/config.go b/internal/infra/config.go new file mode 100644 index 0000000..93b74a2 --- /dev/null +++ b/internal/infra/config.go @@ -0,0 +1,67 @@ +package infra + +import ( + "os" + "path/filepath" + "time" +) + +type Config struct { + HealthCheckTimeout time.Duration `yaml:"health_check_timeout"` + LogTimeout time.Duration `yaml:"log_timeout"` + OperationTimeout time.Duration `yaml:"operation_timeout"` + OllamaBaseURL string `yaml:"ollama_base_url"` + OllamaDefaultModel string `yaml:"ollama_default_model"` + DevlogsDir string `yaml:"devlogs_dir"` + LogFormat string `yaml:"log_format"` +} + +func DefaultConfig() Config { + homeDir, _ := os.UserHomeDir() + devlogsDir := filepath.Join(homeDir, ".devlogs") + + return Config{ + HealthCheckTimeout: 5 * time.Second, + LogTimeout: 10 * time.Second, + OperationTimeout: 30 * time.Second, + OllamaBaseURL: "http://localhost:11434", + OllamaDefaultModel: "qwen2.5-coder:3b-instruct", + DevlogsDir: devlogsDir, + LogFormat: "jsonl", + } +} + +func (c Config) WithHealthCheckTimeout(d time.Duration) Config { + c.HealthCheckTimeout = d + return c +} + +func (c Config) WithLogTimeout(d time.Duration) Config { + c.LogTimeout = d + return c +} + +func (c Config) WithOperationTimeout(d time.Duration) Config { + c.OperationTimeout = d + return c +} + +func (c Config) WithOllamaBaseURL(url string) Config { + c.OllamaBaseURL = url + return c +} + +func (c Config) WithOllamaDefaultModel(model string) Config { + c.OllamaDefaultModel = model + return c +} + +func (c Config) WithDevlogsDir(dir string) Config { + c.DevlogsDir = dir + return c +} + +func (c Config) WithLogFormat(format string) Config { + c.LogFormat = format + return c +} diff --git a/internal/infra/docker.go b/internal/infra/docker.go index b5453be..9cd47dc 100644 --- a/internal/infra/docker.go +++ b/internal/infra/docker.go @@ -2,82 +2,101 @@ package infra import ( "context" + "encoding/json" "fmt" - "sync" + "io" + "strings" "time" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" ) type ContainerInfo struct { - ID string - Name string - Image string - Status string - State string + ID string + Name string + Image string + Status string + State string + Ports []PortMapping + Created time.Time } -type DockerHealth struct { - Available bool - Version string - Containers []ContainerInfo - Error error +type PortMapping struct { + Private uint16 + Public uint16 + Protocol string + HostIP string } -type DockerClient struct { - cli *client.Client +type ContainerDetail struct { + ContainerInfo + EnvVars []string + Mounts []Mount + NetworkID string + Cmd []string + Uptime string } -var ( - sharedDockerClient *DockerClient - dockerClientOnce sync.Once - dockerClientErr error - dockerClientMu sync.RWMutex -) - -func GetSharedDockerClient() (*DockerClient, error) { - dockerClientOnce.Do(func() { - sharedDockerClient, dockerClientErr = NewDockerClient() - }) +type Mount struct { + Source string + Destination string + Type string + ReadOnly bool +} - if dockerClientErr != nil { - return nil, dockerClientErr - } +type ContainerStatsSnapshot struct { + CPUPercent float64 + MemUsed uint64 + MemLimit uint64 + MemPercent float64 + NetRx uint64 + NetTx uint64 + BlockRead uint64 + BlockWrite uint64 + PIDs uint64 + Timestamp time.Time +} - dockerClientMu.RLock() - c := sharedDockerClient - dockerClientMu.RUnlock() +type ImageInfo struct { + ID string + Tags []string + Size int64 + Created time.Time +} - if c != nil && c.cli != nil { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - if _, err := c.cli.Ping(ctx); err == nil { - return c, nil - } +type VolumeInfo struct { + Name string + Driver string + Mountpoint string + CreatedAt time.Time +} - dockerClientMu.Lock() - if sharedDockerClient != nil { - sharedDockerClient.Close() - } - sharedDockerClient, dockerClientErr = NewDockerClient() - dockerClientMu.Unlock() - } +type ProcessInfo struct { + PID string + User string + CPU string + Memory string + Command string +} - return sharedDockerClient, dockerClientErr +type DockerHealth struct { + Available bool + Version string + Containers []ContainerInfo + Error error } -func ResetSharedDockerClient() { - dockerClientMu.Lock() - defer dockerClientMu.Unlock() - if sharedDockerClient != nil { - sharedDockerClient.Close() - sharedDockerClient = nil - } - dockerClientOnce = sync.Once{} - dockerClientErr = nil +type DockerClient struct { + cli *client.Client } +// Note: Shared client management has moved to Registry. +// Use GetRegistry().Docker() instead of the deprecated GetSharedDockerClient(). + func NewDockerClient() (*DockerClient, error) { cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { @@ -119,12 +138,25 @@ func (d *DockerClient) CheckHealth(ctx context.Context) DockerHealth { name = name[1:] } } + + var ports []PortMapping + for _, p := range c.Ports { + ports = append(ports, PortMapping{ + Private: p.PrivatePort, + Public: p.PublicPort, + Protocol: p.Type, + HostIP: p.IP, + }) + } + health.Containers = append(health.Containers, ContainerInfo{ - ID: c.ID[:12], - Name: name, - Image: c.Image, - Status: c.Status, - State: c.State, + ID: c.ID[:12], + Name: name, + Image: c.Image, + Status: c.Status, + State: c.State, + Ports: ports, + Created: time.Unix(c.Created, 0), }) } @@ -181,3 +213,482 @@ func (d *DockerClient) Close() error { } return nil } + +// Container Control Methods + +func (d *DockerClient) StartContainer(ctx context.Context, containerID string) error { + return d.cli.ContainerStart(ctx, containerID, container.StartOptions{}) +} + +func (d *DockerClient) StopContainer(ctx context.Context, containerID string) error { + timeout := 10 + return d.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}) +} + +func (d *DockerClient) RestartContainer(ctx context.Context, containerID string) error { + timeout := 10 + return d.cli.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout}) +} + +func (d *DockerClient) RemoveContainer(ctx context.Context, containerID string, force bool) error { + return d.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{ + Force: force, + RemoveVolumes: false, + }) +} + +func (d *DockerClient) KillContainer(ctx context.Context, containerID string) error { + return d.cli.ContainerKill(ctx, containerID, "SIGKILL") +} + +func (d *DockerClient) PauseContainer(ctx context.Context, containerID string) error { + return d.cli.ContainerPause(ctx, containerID) +} + +func (d *DockerClient) UnpauseContainer(ctx context.Context, containerID string) error { + return d.cli.ContainerUnpause(ctx, containerID) +} + +// Stats Streaming + +func (d *DockerClient) GetContainerStats(ctx context.Context, containerID string) (*ContainerStatsSnapshot, error) { + stats, err := d.cli.ContainerStats(ctx, containerID, false) + if err != nil { + return nil, fmt.Errorf("get stats failed: %w", err) + } + defer stats.Body.Close() + + // Define inline struct matching Docker stats JSON response + var v struct { + CPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemUsage uint64 `json:"system_cpu_usage"` + OnlineCPUs uint64 `json:"online_cpus"` + } `json:"cpu_stats"` + PreCPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemUsage uint64 `json:"system_cpu_usage"` + } `json:"precpu_stats"` + MemoryStats struct { + Usage uint64 `json:"usage"` + Limit uint64 `json:"limit"` + Stats map[string]uint64 `json:"stats"` + } `json:"memory_stats"` + Networks map[string]struct { + RxBytes uint64 `json:"rx_bytes"` + TxBytes uint64 `json:"tx_bytes"` + } `json:"networks"` + BlkioStats struct { + IoServiceBytesRecursive []struct { + Op string `json:"op"` + Value uint64 `json:"value"` + } `json:"io_service_bytes_recursive"` + } `json:"blkio_stats"` + PidsStats struct { + Current uint64 `json:"current"` + } `json:"pids_stats"` + } + + if err := json.NewDecoder(stats.Body).Decode(&v); err != nil { + return nil, fmt.Errorf("decode stats failed: %w", err) + } + + snapshot := &ContainerStatsSnapshot{ + Timestamp: time.Now(), + PIDs: v.PidsStats.Current, + } + + // Calculate CPU percentage + cpuDelta := float64(v.CPUStats.CPUUsage.TotalUsage - v.PreCPUStats.CPUUsage.TotalUsage) + systemDelta := float64(v.CPUStats.SystemUsage - v.PreCPUStats.SystemUsage) + if systemDelta > 0 && cpuDelta > 0 { + snapshot.CPUPercent = (cpuDelta / systemDelta) * float64(v.CPUStats.OnlineCPUs) * 100.0 + } + + // Memory + cacheVal := uint64(0) + if v.MemoryStats.Stats != nil { + cacheVal = v.MemoryStats.Stats["cache"] + } + snapshot.MemUsed = v.MemoryStats.Usage - cacheVal + snapshot.MemLimit = v.MemoryStats.Limit + if snapshot.MemLimit > 0 { + snapshot.MemPercent = float64(snapshot.MemUsed) / float64(snapshot.MemLimit) * 100.0 + } + + // Network I/O + for _, netStats := range v.Networks { + snapshot.NetRx += netStats.RxBytes + snapshot.NetTx += netStats.TxBytes + } + + // Block I/O + for _, bioEntry := range v.BlkioStats.IoServiceBytesRecursive { + switch bioEntry.Op { + case "Read", "read": + snapshot.BlockRead += bioEntry.Value + case "Write", "write": + snapshot.BlockWrite += bioEntry.Value + } + } + + return snapshot, nil +} + +// Container Inspection + +func (d *DockerClient) InspectContainer(ctx context.Context, containerID string) (*ContainerDetail, error) { + info, err := d.cli.ContainerInspect(ctx, containerID) + if err != nil { + return nil, fmt.Errorf("inspect failed: %w", err) + } + + name := info.Name + if len(name) > 0 && name[0] == '/' { + name = name[1:] + } + + createdTime, _ := time.Parse(time.RFC3339Nano, info.Created) + + detail := &ContainerDetail{ + ContainerInfo: ContainerInfo{ + ID: info.ID[:12], + Name: name, + Image: info.Config.Image, + Status: info.State.Status, + State: info.State.Status, + Created: createdTime, + }, + EnvVars: info.Config.Env, + Cmd: info.Config.Cmd, + } + + // Calculate uptime + if info.State.Running { + startTime, _ := time.Parse(time.RFC3339Nano, info.State.StartedAt) + detail.Uptime = time.Since(startTime).Round(time.Second).String() + } + + // Mounts + for _, m := range info.Mounts { + detail.Mounts = append(detail.Mounts, Mount{ + Source: m.Source, + Destination: m.Destination, + Type: string(m.Type), + ReadOnly: !m.RW, + }) + } + + // Network + for netName := range info.NetworkSettings.Networks { + detail.NetworkID = netName + break + } + + // Ports + for portProto, bindings := range info.NetworkSettings.Ports { + for _, b := range bindings { + var publicPort uint16 + if b.HostPort != "" { + fmt.Sscanf(b.HostPort, "%d", &publicPort) + } + detail.Ports = append(detail.Ports, PortMapping{ + Private: uint16(portProto.Int()), + Public: publicPort, + Protocol: portProto.Proto(), + HostIP: b.HostIP, + }) + } + } + + return detail, nil +} + +// Images + +func (d *DockerClient) ListImages(ctx context.Context) ([]ImageInfo, error) { + images, err := d.cli.ImageList(ctx, image.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list images failed: %w", err) + } + + var result []ImageInfo + for _, img := range images { + id := img.ID + if len(id) > 19 { + id = id[7:19] // Remove "sha256:" prefix and truncate + } + result = append(result, ImageInfo{ + ID: id, + Tags: img.RepoTags, + Size: img.Size, + Created: time.Unix(img.Created, 0), + }) + } + return result, nil +} + +func (d *DockerClient) RemoveImage(ctx context.Context, imageID string, force bool) error { + _, err := d.cli.ImageRemove(ctx, imageID, image.RemoveOptions{ + Force: force, + PruneChildren: true, + }) + return err +} + +// Volumes + +func (d *DockerClient) ListVolumes(ctx context.Context) ([]VolumeInfo, error) { + volumes, err := d.cli.VolumeList(ctx, volume.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list volumes failed: %w", err) + } + + var result []VolumeInfo + for _, vol := range volumes.Volumes { + createdAt, _ := time.Parse(time.RFC3339, vol.CreatedAt) + result = append(result, VolumeInfo{ + Name: vol.Name, + Driver: vol.Driver, + Mountpoint: vol.Mountpoint, + CreatedAt: createdAt, + }) + } + return result, nil +} + +func (d *DockerClient) RemoveVolume(ctx context.Context, volumeName string, force bool) error { + return d.cli.VolumeRemove(ctx, volumeName, force) +} + +// Container Processes (docker top) + +func (d *DockerClient) TopContainer(ctx context.Context, containerID string) ([]ProcessInfo, error) { + top, err := d.cli.ContainerTop(ctx, containerID, []string{}) + if err != nil { + return nil, fmt.Errorf("top failed: %w", err) + } + + // Find column indices + pidIdx, userIdx, cmdIdx := -1, -1, -1 + for i, title := range top.Titles { + switch title { + case "PID": + pidIdx = i + case "USER": + userIdx = i + case "CMD", "COMMAND": + cmdIdx = i + } + } + + var result []ProcessInfo + for _, proc := range top.Processes { + info := ProcessInfo{} + if pidIdx >= 0 && pidIdx < len(proc) { + info.PID = proc[pidIdx] + } + if userIdx >= 0 && userIdx < len(proc) { + info.User = proc[userIdx] + } + if cmdIdx >= 0 && cmdIdx < len(proc) { + info.Command = proc[cmdIdx] + } + result = append(result, info) + } + return result, nil +} + +// Bulk operations + +func (d *DockerClient) PruneContainers(ctx context.Context) (uint64, error) { + report, err := d.cli.ContainersPrune(ctx, filters.Args{}) + if err != nil { + return 0, err + } + return report.SpaceReclaimed, nil +} + +func (d *DockerClient) PruneImages(ctx context.Context) (uint64, error) { + report, err := d.cli.ImagesPrune(ctx, filters.Args{}) + if err != nil { + return 0, err + } + return report.SpaceReclaimed, nil +} + +func (d *DockerClient) PruneVolumes(ctx context.Context) (uint64, error) { + report, err := d.cli.VolumesPrune(ctx, filters.Args{}) + if err != nil { + return 0, err + } + return report.SpaceReclaimed, nil +} + +// ============================================================================= +// Log Streaming +// ============================================================================= + +// StreamLogs streams container logs to a LogSink. +// Returns when context is cancelled or an error occurs. +func (d *DockerClient) StreamLogs(ctx context.Context, containerID string, containerName string, sink LogSink) error { + options := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + Timestamps: true, + } + + reader, err := d.cli.ContainerLogs(ctx, containerID, options) + if err != nil { + return fmt.Errorf("stream logs: %w", err) + } + defer reader.Close() + + return d.processLogStream(ctx, reader, containerName, sink, nil, nil) +} + +// StreamLogsToWriter streams container logs directly to an io.Writer. +func (d *DockerClient) StreamLogsToWriter(ctx context.Context, containerID string, w io.Writer) error { + options := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + Timestamps: true, + } + + reader, err := d.cli.ContainerLogs(ctx, containerID, options) + if err != nil { + return fmt.Errorf("stream logs: %w", err) + } + defer reader.Close() + + // Docker multiplexes stdout/stderr with 8-byte header + buf := make([]byte, 8192) + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + n, err := reader.Read(buf) + if n > 0 { + // Skip 8-byte header and write content + data := buf[:n] + for len(data) > 8 { + // Header: [stream_type][0][0][0][size1][size2][size3][size4] + size := int(data[4])<<24 | int(data[5])<<16 | int(data[6])<<8 | int(data[7]) + if size <= 0 || size > len(data)-8 { + break + } + if _, werr := w.Write(data[8 : 8+size]); werr != nil { + return werr + } + if _, werr := w.Write([]byte{'\n'}); werr != nil { + return werr + } + data = data[8+size:] + } + } + if err != nil { + if err == io.EOF { + return nil + } + return err + } + } +} + +// StreamLogsWithSnapshots streams logs and captures GPU/container stats at intervals. +func (d *DockerClient) StreamLogsWithSnapshots(ctx context.Context, containerID string, containerName string, sink LogSink, gpu GPUProvider, snapshotInterval time.Duration) error { + options := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + Timestamps: true, + } + + reader, err := d.cli.ContainerLogs(ctx, containerID, options) + if err != nil { + return fmt.Errorf("stream logs: %w", err) + } + defer reader.Close() + + return d.processLogStream(ctx, reader, containerName, sink, gpu, &snapshotInterval) +} + +// processLogStream handles the common log processing logic. +func (d *DockerClient) processLogStream(ctx context.Context, reader io.ReadCloser, containerName string, sink LogSink, gpu GPUProvider, snapshotInterval *time.Duration) error { + buf := make([]byte, 8192) + var lastSnapshot time.Time + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + n, err := reader.Read(buf) + if n > 0 { + data := buf[:n] + for len(data) > 8 { + // Parse Docker log header + streamType := data[0] // 1 = stdout, 2 = stderr + size := int(data[4])<<24 | int(data[5])<<16 | int(data[6])<<8 | int(data[7]) + if size <= 0 || size > len(data)-8 { + break + } + + content := string(data[8 : 8+size]) + stream := "stdout" + if streamType == 2 { + stream = "stderr" + } + + // Parse timestamp from content (RFC3339 format at start) + timestamp := time.Now() + if len(content) > 30 && content[4] == '-' { + if t, perr := time.Parse(time.RFC3339Nano, strings.TrimSpace(content[:30])); perr == nil { + timestamp = t + content = strings.TrimSpace(content[30:]) + } + } + + entry := LogEntry{ + Timestamp: timestamp, + Container: containerName, + Stream: stream, + Message: strings.TrimSuffix(content, "\n"), + } + + // Add snapshots at interval + if snapshotInterval != nil && time.Since(lastSnapshot) >= *snapshotInterval { + if gpu != nil { + gpuStats := gpu.GetStats() + entry.GPUSnapshot = &gpuStats + } + // Could add container stats here too + lastSnapshot = time.Now() + } + + if werr := sink.Write(entry); werr != nil { + return werr + } + + data = data[8+size:] + } + } + if err != nil { + if err == io.EOF { + return nil + } + return err + } + } +} diff --git a/internal/infra/docker_test.go b/internal/infra/docker_test.go new file mode 100644 index 0000000..5d0f4f1 --- /dev/null +++ b/internal/infra/docker_test.go @@ -0,0 +1,298 @@ +package infra + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// Integration tests using Testcontainers +// Run with: go test -v -race -run Integration ./internal/infra/... +// Skip with: go test -short ./... + +func TestIntegration_DockerClient_CheckHealth(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + // Start a simple alpine container + req := testcontainers.ContainerRequest{ + Image: "alpine:latest", + Cmd: []string{"sleep", "30"}, + WaitingFor: wait.ForLog("").WithStartupTimeout(10 * time.Second), + ExposedPorts: []string{}, + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start container: %v", err) + } + defer container.Terminate(ctx) + + // Test our DockerClient can see this container + client, err := NewDockerClient() + if err != nil { + t.Fatalf("failed to create docker client: %v", err) + } + defer client.Close() + + health := client.CheckHealth(ctx) + if !health.Available { + t.Fatalf("docker should be available: %v", health.Error) + } + + // Verify we can see at least one container + if len(health.Containers) == 0 { + t.Error("expected at least one container to be visible") + } + + // Find our test container + containerID := container.GetContainerID() + + found := false + for _, c := range health.Containers { + if c.ID == containerID || c.ID[:12] == containerID[:12] { + found = true + if c.State != "running" { + t.Errorf("expected container state 'running', got '%s'", c.State) + } + break + } + } + + if !found { + t.Error("test container not found in health check") + } +} + +func TestIntegration_DockerClient_StartStop(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + // Start a container that stays running + req := testcontainers.ContainerRequest{ + Image: "alpine:latest", + Cmd: []string{"sleep", "60"}, + WaitingFor: wait.ForLog("").WithStartupTimeout(10 * time.Second), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start container: %v", err) + } + defer container.Terminate(ctx) + + containerID := container.GetContainerID() + + client, err := NewDockerClient() + if err != nil { + t.Fatalf("failed to create docker client: %v", err) + } + defer client.Close() + + // Test stop + if err := client.StopContainer(ctx, containerID); err != nil { + t.Fatalf("failed to stop container: %v", err) + } + + // Verify stopped + time.Sleep(500 * time.Millisecond) + health := client.CheckHealth(ctx) + for _, c := range health.Containers { + if c.ID == containerID || c.ID[:12] == containerID[:12] { + if c.State == "running" { + t.Error("container should not be running after stop") + } + break + } + } + + // Test start + if err := client.StartContainer(ctx, containerID); err != nil { + t.Fatalf("failed to start container: %v", err) + } + + // Verify running + time.Sleep(500 * time.Millisecond) + health = client.CheckHealth(ctx) + for _, c := range health.Containers { + if c.ID == containerID || c.ID[:12] == containerID[:12] { + if c.State != "running" { + t.Errorf("container should be running after start, got '%s'", c.State) + } + break + } + } +} + +func TestIntegration_DockerClient_ContainerLogs(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + // Container that outputs something + req := testcontainers.ContainerRequest{ + Image: "alpine:latest", + Cmd: []string{"sh", "-c", "echo 'test-log-output' && sleep 5"}, + WaitingFor: wait.ForLog("test-log-output").WithStartupTimeout(10 * time.Second), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start container: %v", err) + } + defer container.Terminate(ctx) + + containerID := container.GetContainerID() + + client, err := NewDockerClient() + if err != nil { + t.Fatalf("failed to create docker client: %v", err) + } + defer client.Close() + + // Give container time to output logs + time.Sleep(1 * time.Second) + + logs, err := client.GetContainerLogs(ctx, containerID, 10) + if err != nil { + t.Fatalf("failed to get container logs: %v", err) + } + + if len(logs) == 0 { + t.Error("expected at least one log line") + } + + // Check for our expected output + found := false + for _, line := range logs { + if line == "test-log-output" { + found = true + break + } + } + + if !found { + t.Errorf("expected log containing 'test-log-output', got: %v", logs) + } +} + +func TestIntegration_MockOllamaAPI(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + ctx := context.Background() + + // Use nginx to mock the Ollama API with a static response + nginxConf := ` +events {} +http { + server { + listen 11434; + location /api/tags { + default_type application/json; + return 200 '{"models":[{"name":"qwen2.5-coder:3b-instruct","modified_at":"2024-01-01T00:00:00Z","size":1234567890,"digest":"abc123"}]}'; + } + } +} +` + + req := testcontainers.ContainerRequest{ + Image: "nginx:alpine", + ExposedPorts: []string{"11434/tcp"}, + Files: []testcontainers.ContainerFile{ + { + HostFilePath: "", + Reader: nil, + ContainerFilePath: "/etc/nginx/nginx.conf", + }, + }, + Cmd: []string{"sh", "-c", fmt.Sprintf("echo '%s' > /etc/nginx/nginx.conf && nginx -g 'daemon off;'", nginxConf)}, + WaitingFor: wait.ForHTTP("/api/tags").WithPort("11434/tcp").WithStartupTimeout(30 * time.Second), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start mock ollama container: %v", err) + } + defer container.Terminate(ctx) + + // Get the mapped port + mappedPort, err := container.MappedPort(ctx, "11434") + if err != nil { + t.Fatalf("failed to get mapped port: %v", err) + } + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("failed to get host: %v", err) + } + + baseURL := fmt.Sprintf("http://%s:%s", host, mappedPort.Port()) + + // Test OllamaClient against mock + ollamaClient := NewOllamaClient(nil, baseURL) + + // Test Ping + if err := ollamaClient.Ping(ctx); err != nil { + t.Fatalf("ping failed: %v", err) + } + + // Test ListModels + models, err := ollamaClient.ListModels(ctx) + if err != nil { + t.Fatalf("list models failed: %v", err) + } + + if len(models) != 1 { + t.Errorf("expected 1 model, got %d", len(models)) + } + + if len(models) > 0 && models[0].Name != "qwen2.5-coder:3b-instruct" { + t.Errorf("expected model name 'qwen2.5-coder:3b-instruct', got '%s'", models[0].Name) + } + + // Test HasModel + hasModel, err := ollamaClient.HasModel(ctx, "qwen2.5-coder") + if err != nil { + t.Fatalf("has model failed: %v", err) + } + + if !hasModel { + t.Error("expected HasModel to return true for 'qwen2.5-coder'") + } + + // Test HasModel for non-existent model + hasModel, err = ollamaClient.HasModel(ctx, "nonexistent-model") + if err != nil { + t.Fatalf("has model failed: %v", err) + } + + if hasModel { + t.Error("expected HasModel to return false for 'nonexistent-model'") + } +} diff --git a/internal/infra/logsink.go b/internal/infra/logsink.go new file mode 100644 index 0000000..23a2e97 --- /dev/null +++ b/internal/infra/logsink.go @@ -0,0 +1,222 @@ +package infra + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" +) + +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Container string `json:"container"` + Stream string `json:"stream"` + Message string `json:"message"` + GPUSnapshot *GPUStats `json:"gpu,omitempty"` + ContainerSnap *ContainerStatsSnapshot `json:"container_stats,omitempty"` +} + +type LogSink interface { + Write(entry LogEntry) error + Close() error +} + +type FileLogSink struct { + mu sync.Mutex + file *os.File + writer *bufio.Writer + filePath string +} + +func NewFileLogSink(path string) (*FileLogSink, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create log directory: %w", err) + } + + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("open log file: %w", err) + } + + return &FileLogSink{ + file: file, + writer: bufio.NewWriter(file), + filePath: path, + }, nil +} + +func (s *FileLogSink) Write(entry LogEntry) error { + s.mu.Lock() + defer s.mu.Unlock() + + line := fmt.Sprintf("[%s] [%s] %s\n", + entry.Timestamp.Format(time.RFC3339), + entry.Stream, + entry.Message, + ) + + _, err := s.writer.WriteString(line) + return err +} + +func (s *FileLogSink) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + if err := s.writer.Flush(); err != nil { + return err + } + return s.file.Close() +} + +func (s *FileLogSink) Path() string { + return s.filePath +} + +type JSONLLogSink struct { + mu sync.Mutex + file *os.File + encoder *json.Encoder + filePath string +} + +func NewJSONLLogSink(path string) (*JSONLLogSink, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create log directory: %w", err) + } + + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("open log file: %w", err) + } + + return &JSONLLogSink{ + file: file, + encoder: json.NewEncoder(file), + filePath: path, + }, nil +} + +func (s *JSONLLogSink) Write(entry LogEntry) error { + s.mu.Lock() + defer s.mu.Unlock() + return s.encoder.Encode(entry) +} + +func (s *JSONLLogSink) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + return s.file.Close() +} + +func (s *JSONLLogSink) Path() string { + return s.filePath +} + +type MultiLogSink struct { + mu sync.Mutex + sinks []LogSink +} + +func NewMultiLogSink(sinks ...LogSink) *MultiLogSink { + return &MultiLogSink{ + sinks: sinks, + } +} + +func (s *MultiLogSink) AddSink(sink LogSink) { + s.mu.Lock() + defer s.mu.Unlock() + s.sinks = append(s.sinks, sink) +} + +func (s *MultiLogSink) Write(entry LogEntry) error { + s.mu.Lock() + defer s.mu.Unlock() + + var firstErr error + for _, sink := range s.sinks { + if err := sink.Write(entry); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func (s *MultiLogSink) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + var firstErr error + for _, sink := range s.sinks { + if err := sink.Close(); err != nil && firstErr == nil { + firstErr = err + } + } + return firstErr +} + +type WriterLogSink struct { + mu sync.Mutex + writer io.Writer + format string +} + +func NewWriterLogSink(w io.Writer, format string) *WriterLogSink { + if format == "" { + format = "text" + } + return &WriterLogSink{ + writer: w, + format: format, + } +} + +func (s *WriterLogSink) Write(entry LogEntry) error { + s.mu.Lock() + defer s.mu.Unlock() + + var data []byte + var err error + + if s.format == "jsonl" { + data, err = json.Marshal(entry) + if err != nil { + return err + } + data = append(data, '\n') + } else { + line := fmt.Sprintf("[%s] [%s] %s\n", + entry.Timestamp.Format(time.RFC3339), + entry.Stream, + entry.Message, + ) + data = []byte(line) + } + + _, err = s.writer.Write(data) + return err +} + +func (s *WriterLogSink) Close() error { + return nil +} + +func CreateLogSink(config Config, containerName string) (LogSink, error) { + timestamp := time.Now().Format("20060102-150405") + filename := fmt.Sprintf("docker-%s-%s", containerName, timestamp) + + var path string + if config.LogFormat == "jsonl" { + path = filepath.Join(config.DevlogsDir, filename+".jsonl") + return NewJSONLLogSink(path) + } + path = filepath.Join(config.DevlogsDir, filename+".log") + return NewFileLogSink(path) +} diff --git a/internal/infra/ollama.go b/internal/infra/ollama.go new file mode 100644 index 0000000..474eea1 --- /dev/null +++ b/internal/infra/ollama.go @@ -0,0 +1,242 @@ +package infra + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type OllamaClient struct { + baseURL string + httpClient *http.Client + docker *DockerClient +} + +type OllamaModel struct { + Name string `json:"name"` + ModifiedAt time.Time `json:"modified_at"` + Size int64 `json:"size"` + Digest string `json:"digest"` +} + +type OllamaTagsResponse struct { + Models []OllamaModel `json:"models"` +} + +type OllamaPullProgress struct { + Status string `json:"status"` + Digest string `json:"digest,omitempty"` + Total int64 `json:"total,omitempty"` + Completed int64 `json:"completed,omitempty"` +} + +func NewOllamaClient(docker *DockerClient, baseURL string) *OllamaClient { + if baseURL == "" { + baseURL = "http://localhost:11434" + } + return &OllamaClient{ + baseURL: strings.TrimSuffix(baseURL, "/"), + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + docker: docker, + } +} + +func (o *OllamaClient) Ping(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "GET", o.baseURL+"/api/tags", nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + resp, err := o.httpClient.Do(req) + if err != nil { + return fmt.Errorf("ollama not responding: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("ollama returned status %d", resp.StatusCode) + } + + return nil +} + +func (o *OllamaClient) ListModels(ctx context.Context) ([]OllamaModel, error) { + req, err := http.NewRequestWithContext(ctx, "GET", o.baseURL+"/api/tags", nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := o.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("list models: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("list models failed with status %d", resp.StatusCode) + } + + var tagsResp OllamaTagsResponse + if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + + return tagsResp.Models, nil +} + +func (o *OllamaClient) HasModel(ctx context.Context, name string) (bool, error) { + models, err := o.ListModels(ctx) + if err != nil { + return false, err + } + + searchName := strings.ToLower(name) + + for _, model := range models { + modelName := strings.ToLower(model.Name) + if modelName == searchName || strings.HasPrefix(modelName, searchName+":") { + return true, nil + } + } + + return false, nil +} + +func (o *OllamaClient) PullModel(ctx context.Context, name string) (io.ReadCloser, error) { + payload := fmt.Sprintf(`{"name": "%s", "stream": true}`, name) + req, err := http.NewRequestWithContext(ctx, "POST", o.baseURL+"/api/pull", strings.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Timeout: 0, + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("pull model: %w", err) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("pull failed with status %d", resp.StatusCode) + } + + return resp.Body, nil +} + +func (o *OllamaClient) PullModelSync(ctx context.Context, name string, progressFn func(OllamaPullProgress)) error { + reader, err := o.PullModel(ctx, name) + if err != nil { + return err + } + defer reader.Close() + + decoder := json.NewDecoder(reader) + for { + var progress OllamaPullProgress + if err := decoder.Decode(&progress); err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("decode progress: %w", err) + } + + if progressFn != nil { + progressFn(progress) + } + + if progress.Status == "success" { + break + } + } + + return nil +} + +func (o *OllamaClient) EnsureModel(ctx context.Context, name string, progressFn func(OllamaPullProgress)) error { + if err := o.EnsureContainer(ctx); err != nil { + return fmt.Errorf("ensure container: %w", err) + } + + if err := o.waitForAPI(ctx); err != nil { + return fmt.Errorf("wait for API: %w", err) + } + + hasModel, err := o.HasModel(ctx, name) + if err != nil { + return fmt.Errorf("check model: %w", err) + } + + if hasModel { + return nil + } + + return o.PullModelSync(ctx, name, progressFn) +} + +func (o *OllamaClient) EnsureContainer(ctx context.Context) error { + if o.docker == nil { + return fmt.Errorf("docker client not available") + } + + health := o.docker.CheckHealth(ctx) + if !health.Available { + return fmt.Errorf("docker not available: %v", health.Error) + } + + var ollamaContainer *ContainerInfo + for i := range health.Containers { + c := &health.Containers[i] + if strings.Contains(strings.ToLower(c.Name), "ollama") || + strings.Contains(strings.ToLower(c.Image), "ollama") { + ollamaContainer = c + break + } + } + + if ollamaContainer == nil { + return fmt.Errorf("ollama container not found - run 'docker compose up -d' in infra/ollama") + } + + if ollamaContainer.State != "running" { + if err := o.docker.StartContainer(ctx, ollamaContainer.ID); err != nil { + return fmt.Errorf("start container: %w", err) + } + + time.Sleep(2 * time.Second) + } + + return nil +} + +func (o *OllamaClient) waitForAPI(ctx context.Context) error { + maxAttempts := 30 + delay := 500 * time.Millisecond + + for i := 0; i < maxAttempts; i++ { + if err := o.Ping(ctx); err == nil { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(delay): + } + } + + return fmt.Errorf("ollama API not available after %d attempts", maxAttempts) +} + +func (o *OllamaClient) BaseURL() string { + return o.baseURL +} diff --git a/internal/infra/registry.go b/internal/infra/registry.go index 00a239d..c1e22a8 100644 --- a/internal/infra/registry.go +++ b/internal/infra/registry.go @@ -6,7 +6,10 @@ import ( type Registry struct { mu sync.RWMutex + config Config docker *DockerClient + ollama *OllamaClient + gpu GPUProvider } var ( @@ -16,11 +19,28 @@ var ( func GetRegistry() *Registry { registryOnce.Do(func() { - registry = &Registry{} + registry = &Registry{ + config: DefaultConfig(), + } }) return registry } +func GetRegistryWithConfig(config Config) *Registry { + registryOnce.Do(func() { + registry = &Registry{ + config: config, + } + }) + return registry +} + +func (r *Registry) Config() Config { + r.mu.RLock() + defer r.mu.RUnlock() + return r.config +} + func (r *Registry) Docker() (*DockerClient, error) { r.mu.RLock() if r.docker != nil { @@ -44,6 +64,62 @@ func (r *Registry) Docker() (*DockerClient, error) { return r.docker, nil } +func (r *Registry) Ollama() (*OllamaClient, error) { + r.mu.RLock() + if r.ollama != nil { + r.mu.RUnlock() + return r.ollama, nil + } + r.mu.RUnlock() + + r.mu.Lock() + defer r.mu.Unlock() + + if r.ollama != nil { + return r.ollama, nil + } + + docker, err := r.dockerUnsafe() + if err != nil { + return nil, err + } + + r.ollama = NewOllamaClient(docker, r.config.OllamaBaseURL) + return r.ollama, nil +} + +func (r *Registry) dockerUnsafe() (*DockerClient, error) { + if r.docker != nil { + return r.docker, nil + } + + client, err := NewDockerClient() + if err != nil { + return nil, err + } + r.docker = client + return r.docker, nil +} + +func (r *Registry) GPU() GPUProvider { + r.mu.RLock() + if r.gpu != nil { + r.mu.RUnlock() + return r.gpu + } + r.mu.RUnlock() + + r.mu.Lock() + defer r.mu.Unlock() + + if r.gpu != nil { + return r.gpu + } + + r.gpu = DetectGPU() + return r.gpu +} + func (r *Registry) Close() error { r.mu.Lock() defer r.mu.Unlock() @@ -52,6 +128,8 @@ func (r *Registry) Close() error { r.docker.Close() r.docker = nil } + r.ollama = nil + r.gpu = nil return nil } @@ -62,3 +140,11 @@ func ResetRegistry() { registryOnce = sync.Once{} registry = nil } + +func GetSharedDockerClient() (*DockerClient, error) { + return GetRegistry().Docker() +} + +func ResetSharedDockerClient() { + ResetRegistry() +} diff --git a/internal/infra/system.go b/internal/infra/system.go index 9e6e77c..1ee4e11 100644 --- a/internal/infra/system.go +++ b/internal/infra/system.go @@ -6,23 +6,55 @@ import ( "os" "os/exec" "regexp" + "runtime" "strconv" "strings" "time" ) +type GPUProvider interface { + GetStats() GPUStats + Vendor() string +} + type GPUStats struct { Available bool + Vendor string UsedMemoryMB int TotalMemoryMB int UtilizationPct int + Temperature int Error error } -func GetGPUStats() GPUStats { - stats := GPUStats{} +func DetectGPU() GPUProvider { + if _, err := exec.LookPath("nvidia-smi"); err == nil { + return &NvidiaGPUProvider{} + } + + if _, err := exec.LookPath("rocm-smi"); err == nil { + return &AMDGPUProvider{} + } + + if runtime.GOOS == "darwin" { + return &AppleGPUProvider{} + } + + return &NoGPUProvider{} +} + +type NvidiaGPUProvider struct{} + +func (p *NvidiaGPUProvider) Vendor() string { + return "nvidia" +} + +func (p *NvidiaGPUProvider) GetStats() GPUStats { + stats := GPUStats{Vendor: "nvidia"} - cmd := exec.Command("nvidia-smi", "--query-gpu=memory.used,memory.total", "--format=csv,noheader,nounits") + cmd := exec.Command("nvidia-smi", + "--query-gpu=memory.used,memory.total,utilization.gpu,temperature.gpu", + "--format=csv,noheader,nounits") output, err := cmd.Output() if err != nil { stats.Available = false @@ -31,7 +63,7 @@ func GetGPUStats() GPUStats { } parts := strings.Split(strings.TrimSpace(string(output)), ",") - if len(parts) != 2 { + if len(parts) < 2 { stats.Available = false stats.Error = fmt.Errorf("unexpected output format") return stats @@ -53,9 +85,142 @@ func GetGPUStats() GPUStats { stats.UtilizationPct = (used * 100) / total } + if len(parts) >= 3 { + if util, err := strconv.Atoi(strings.TrimSpace(parts[2])); err == nil { + stats.UtilizationPct = util + } + } + + if len(parts) >= 4 { + if temp, err := strconv.Atoi(strings.TrimSpace(parts[3])); err == nil { + stats.Temperature = temp + } + } + + return stats +} + +type AMDGPUProvider struct{} + +func (p *AMDGPUProvider) Vendor() string { + return "amd" +} + +func (p *AMDGPUProvider) GetStats() GPUStats { + stats := GPUStats{Vendor: "amd"} + + cmd := exec.Command("rocm-smi", "--showmeminfo", "vram", "--csv") + output, err := cmd.Output() + if err != nil { + stats.Available = false + stats.Error = err + return stats + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.Contains(line, "GPU") && strings.Contains(line, ",") { + parts := strings.Split(line, ",") + if len(parts) >= 3 { + if used, err := parseMemoryMB(strings.TrimSpace(parts[1])); err == nil { + stats.UsedMemoryMB = used + } + if total, err := parseMemoryMB(strings.TrimSpace(parts[2])); err == nil { + stats.TotalMemoryMB = total + } + } + } + } + + tempCmd := exec.Command("rocm-smi", "-t", "--csv") + tempOutput, err := tempCmd.Output() + if err == nil { + lines := strings.Split(string(tempOutput), "\n") + for _, line := range lines { + if strings.Contains(line, "GPU") { + parts := strings.Split(line, ",") + if len(parts) >= 2 { + if temp, err := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64); err == nil { + stats.Temperature = int(temp) + } + } + } + } + } + + stats.Available = stats.TotalMemoryMB > 0 + if stats.TotalMemoryMB > 0 { + stats.UtilizationPct = (stats.UsedMemoryMB * 100) / stats.TotalMemoryMB + } + + return stats +} + +func parseMemoryMB(s string) (int, error) { + s = strings.ToUpper(strings.TrimSpace(s)) + s = strings.ReplaceAll(s, " ", "") + + if strings.HasSuffix(s, "GB") || strings.HasSuffix(s, "G") { + s = strings.TrimSuffix(strings.TrimSuffix(s, "GB"), "G") + if val, err := strconv.ParseFloat(s, 64); err == nil { + return int(val * 1024), nil + } + } + if strings.HasSuffix(s, "MB") || strings.HasSuffix(s, "M") { + s = strings.TrimSuffix(strings.TrimSuffix(s, "MB"), "M") + if val, err := strconv.ParseFloat(s, 64); err == nil { + return int(val), nil + } + } + if val, err := strconv.Atoi(s); err == nil { + return val, nil + } + return 0, fmt.Errorf("cannot parse: %s", s) +} + +type AppleGPUProvider struct{} + +func (p *AppleGPUProvider) Vendor() string { + return "apple" +} + +func (p *AppleGPUProvider) GetStats() GPUStats { + stats := GPUStats{Vendor: "apple"} + + cmd := exec.Command("system_profiler", "SPDisplaysDataType", "-json") + output, err := cmd.Output() + if err != nil { + stats.Available = false + stats.Error = err + return stats + } + + if strings.Contains(string(output), "Apple") || strings.Contains(string(output), "M1") || + strings.Contains(string(output), "M2") || strings.Contains(string(output), "M3") { + stats.Available = true + } + return stats } +type NoGPUProvider struct{} + +func (p *NoGPUProvider) Vendor() string { + return "none" +} + +func (p *NoGPUProvider) GetStats() GPUStats { + return GPUStats{ + Available: false, + Vendor: "none", + Error: fmt.Errorf("no supported GPU detected"), + } +} + +func GetGPUStats() GPUStats { + return DetectGPU().GetStats() +} + type ServiceStatus struct { Name string Port int @@ -95,11 +260,81 @@ func CheckServices() []ServiceStatus { return results } +type PortConflict struct { + Port int + Process string + PID int + Suggested int +} + +func CheckPortAvailable(port int) *PortConflict { + addr := fmt.Sprintf("localhost:%d", port) + conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) + if err != nil { + return nil + } + conn.Close() + + pid, process, _ := GetProcessOnPort(port) + + return &PortConflict{ + Port: port, + Process: process, + PID: pid, + Suggested: FindAvailablePort(port + 1), + } +} + +func FindAvailablePort(basePort int) int { + for port := basePort; port < basePort+100; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err == nil { + ln.Close() + return port + } + } + return 0 +} + +func GetProcessOnPort(port int) (pid int, process string, err error) { + cmd := exec.Command("lsof", "-i", fmt.Sprintf(":%d", port), "-t") + output, err := cmd.Output() + if err != nil { + return 0, "", err + } + + pidStr := strings.TrimSpace(string(output)) + lines := strings.Split(pidStr, "\n") + if len(lines) == 0 || lines[0] == "" { + return 0, "", fmt.Errorf("no process found") + } + + pid, err = strconv.Atoi(lines[0]) + if err != nil { + return 0, "", err + } + + if runtime.GOOS == "linux" { + cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err == nil { + process = strings.TrimSpace(string(cmdline)) + } + } else { + psCmd := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "comm=") + psOutput, err := psCmd.Output() + if err == nil { + process = strings.TrimSpace(string(psOutput)) + } + } + + return pid, process, nil +} + type StarshipPrompt struct { Available bool - Raw string // Raw prompt output - Clean string // ANSI-stripped version - Segments []string // Parsed segments + Raw string + Clean string + Segments []string } func GetStarshipPrompt() StarshipPrompt { diff --git a/internal/tui/app.go b/internal/tui/app.go index 5a5d9b1..5d8b0fe 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -139,7 +139,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dockerHealthMsg: m.agent = m.agent.SetDockerHealth(msg.health) - m.containers = m.containers.SetDockerHealth(msg.health) + m.containers = m.containers.SetServices(msg.health.Containers) if msg.health.Available { m.state = StateMain if len(msg.health.Containers) > 0 { @@ -199,8 +199,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.activeTab = TabAgent case "2": m.activeTab = TabContainers - if m.containers.SelectedContainer() != nil { - cmds = append(cmds, fetchContainerLogs(m.containers.SelectedContainer().ID)) + if m.containers.SelectedService() != nil { + cmds = append(cmds, fetchContainerLogs(m.containers.SelectedService().ID)) } case "3": m.activeTab = TabHistory @@ -218,13 +218,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) case TabContainers: - oldCursor := m.containers.ContainerCursor() + oldCursor := m.containers.ServicesList().Index() m.containers, cmd = m.containers.Update(msg, monitor.DefaultKeyMap()) cmds = append(cmds, cmd) - if m.containers.ContainerCursor() != oldCursor { - if container := m.containers.SelectedContainer(); container != nil { - cmds = append(cmds, fetchContainerLogs(container.ID)) + if m.containers.ServicesList().Index() != oldCursor { + if svc := m.containers.SelectedService(); svc != nil { + cmds = append(cmds, fetchContainerLogs(svc.ID)) } } @@ -317,8 +317,10 @@ func (m Model) getFocusLabel() string { return "agent" case TabContainers: switch m.containers.Focus() { - case monitor.FocusList: - return "containers" + case monitor.FocusServices: + return "services" + case monitor.FocusImages: + return "images" case monitor.FocusLogs: return "logs" case monitor.FocusStats: diff --git a/internal/tui/tabs/monitor/model.go b/internal/tui/tabs/monitor/model.go index 4f909c5..175c277 100644 --- a/internal/tui/tabs/monitor/model.go +++ b/internal/tui/tabs/monitor/model.go @@ -1,19 +1,138 @@ package monitor import ( + "fmt" + "io" + "os" + "path/filepath" + "time" + "dev-cli/internal/infra" + "dev-cli/internal/tui/theme" + "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" ) type FocusPanel int const ( - FocusList FocusPanel = iota + FocusServices FocusPanel = iota + FocusImages FocusLogs FocusStats ) +// Service item for bubbles/list +type serviceItem struct { + info infra.ContainerInfo +} + +func (i serviceItem) Title() string { return i.info.Name } +func (i serviceItem) Description() string { return i.info.Status } +func (i serviceItem) FilterValue() string { return i.info.Name } + +// Image item for bubbles/list +type imageItem struct { + info infra.ImageInfo +} + +func (i imageItem) Title() string { + if len(i.info.Tags) > 0 { + return i.info.Tags[0] + } + return i.info.ID +} +func (i imageItem) Description() string { return formatSize(i.info.Size) } +func (i imageItem) FilterValue() string { return i.Title() } + +// Custom delegate for service list +type serviceDelegate struct{} + +func (d serviceDelegate) Height() int { return 1 } +func (d serviceDelegate) Spacing() int { return 0 } +func (d serviceDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } + +func (d serviceDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(serviceItem) + if !ok { + return + } + + // Status indicator + status := "●" + statusColor := theme.Green + if i.info.State != "running" { + status = "○" + statusColor = theme.Red + } + + name := i.info.Name + maxWidth := m.Width() - 6 + if maxWidth < 5 { + maxWidth = 5 + } + if len(name) > maxWidth { + name = name[:maxWidth-1] + "…" + } + + statusStyle := lipgloss.NewStyle().Foreground(statusColor) + textStyle := lipgloss.NewStyle().Foreground(theme.Text) + + line := fmt.Sprintf(" %s %s", statusStyle.Render(status), textStyle.Render(name)) + + if index == m.Index() { + line = lipgloss.NewStyle(). + Background(theme.Surface1). + Foreground(theme.Lavender). + Bold(true). + Width(m.Width()). + Render(line) + } + + fmt.Fprint(w, line) +} + +// Custom delegate for image list +type imageDelegate struct{} + +func (d imageDelegate) Height() int { return 1 } +func (d imageDelegate) Spacing() int { return 0 } +func (d imageDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } + +func (d imageDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(imageItem) + if !ok { + return + } + + tag := i.Title() + maxWidth := m.Width() - 4 + if maxWidth < 5 { + maxWidth = 5 + } + if len(tag) > maxWidth { + tag = tag[:maxWidth-1] + "…" + } + + textStyle := lipgloss.NewStyle().Foreground(theme.Text) + line := fmt.Sprintf(" %s", textStyle.Render(tag)) + + if index == m.Index() { + line = lipgloss.NewStyle(). + Background(theme.Surface1). + Foreground(theme.Lavender). + Bold(true). + Width(m.Width()). + Render(line) + } + + fmt.Fprint(w, line) +} + +// Stats for display type ContainerStats struct { CPUHistory []int MemUsed int @@ -27,29 +146,55 @@ type Model struct { height int focus FocusPanel - viewport viewport.Model + // Lists (using bubbles/list like history sidebar) + servicesList list.Model + imagesList list.Model + viewport viewport.Model - dockerHealth infra.DockerHealth + // Data + services []infra.ContainerInfo + images []infra.ImageInfo logLines []string containerStats map[string]ContainerStats - containerCursor int - horizontalOffset int - wrapMode bool - maxLineWidth int + // Log recording + isRecording bool + recordingFile *os.File + recordingPath string - followMode bool - logLevelFilter string // "", "ERROR", "WARN", "INFO" - showingActions bool - actionMenuIndex int + // UI state + followMode bool + logLevelFilter string } func New() Model { + // Services list + sDelegate := serviceDelegate{} + sList := list.New([]list.Item{}, sDelegate, 0, 0) + sList.SetShowHelp(false) + sList.SetShowTitle(false) + sList.SetShowStatusBar(false) + sList.SetFilteringEnabled(false) + sList.DisableQuitKeybindings() + sList.Styles.NoItems = lipgloss.NewStyle().Foreground(theme.Overlay0).Padding(1) + + // Images list + iDelegate := imageDelegate{} + iList := list.New([]list.Item{}, iDelegate, 0, 0) + iList.SetShowHelp(false) + iList.SetShowTitle(false) + iList.SetShowStatusBar(false) + iList.SetFilteringEnabled(false) + iList.DisableQuitKeybindings() + iList.Styles.NoItems = lipgloss.NewStyle().Foreground(theme.Overlay0).Padding(1) + vp := viewport.New(0, 0) return Model{ + servicesList: sList, + imagesList: iList, viewport: vp, - focus: FocusList, + focus: FocusServices, containerStats: make(map[string]ContainerStats), } } @@ -58,137 +203,189 @@ func (m Model) SetSize(w, h int) Model { m.width = w m.height = h - listWidth := 28 + // Left sidebar width + sidebarWidth := 28 if w < 100 { - listWidth = 24 - } - - logWidth := w - listWidth - 4 - if logWidth < 40 { - logWidth = 40 + sidebarWidth = 24 } + // Calculate panel heights panelHeight := h - 4 - statsHeight := 6 - logHeight := panelHeight - statsHeight - 2 + servicesHeight := (panelHeight - 8) / 2 // Half for services + imagesHeight := (panelHeight - 8) / 2 // Half for images + _ = 6 // Stats height (used in view.go) - if logHeight < 10 { - logHeight = 10 + if servicesHeight < 5 { + servicesHeight = 5 } + if imagesHeight < 5 { + imagesHeight = 5 + } + + // Set list dimensions + m.servicesList.SetWidth(sidebarWidth - 4) + m.servicesList.SetHeight(servicesHeight - 2) + m.imagesList.SetWidth(sidebarWidth - 4) + m.imagesList.SetHeight(imagesHeight - 2) + // Viewport for logs + logWidth := w - sidebarWidth - 4 + if logWidth < 40 { + logWidth = 40 + } m.viewport.Width = logWidth - 4 - m.viewport.Height = logHeight - 4 + m.viewport.Height = panelHeight - 4 return m } -func (m Model) SetFocus(f FocusPanel) Model { - m.focus = f +// SetServices updates the services list +func (m Model) SetServices(containers []infra.ContainerInfo) Model { + m.services = containers + + items := make([]list.Item, len(containers)) + for i, c := range containers { + items[i] = serviceItem{info: c} + } + m.servicesList.SetItems(items) + return m } -func (m Model) SetDockerHealth(h infra.DockerHealth) Model { - m.dockerHealth = h +// SetImages updates the images list +func (m Model) SetImages(images []infra.ImageInfo) Model { + m.images = images + + items := make([]list.Item, len(images)) + for i, img := range images { + items[i] = imageItem{info: img} + } + m.imagesList.SetItems(items) + return m } +// SetLogLines updates the log content func (m Model) SetLogLines(lines []string) Model { m.logLines = lines - m.maxLineWidth = 0 - for _, line := range lines { - if len(line) > m.maxLineWidth { - m.maxLineWidth = len(line) + // If recording, write to file + if m.isRecording && m.recordingFile != nil { + for _, line := range lines { + m.recordingFile.WriteString(line + "\n") } } - return m -} -func (m Model) Focus() FocusPanel { - return m.focus + return m } -func (m Model) ContainerCursor() int { - return m.containerCursor -} +// Recording methods +func (m Model) StartRecording() Model { + if m.isRecording { + return m + } -func (m Model) SetContainerCursor(c int) Model { - m.containerCursor = c - return m -} + // Create ~/.devlogs directory + homeDir, err := os.UserHomeDir() + if err != nil { + return m + } -func (m Model) HorizontalOffset() int { - return m.horizontalOffset -} + logDir := filepath.Join(homeDir, ".devlogs") + if err := os.MkdirAll(logDir, 0755); err != nil { + return m + } -func (m Model) SetHorizontalOffset(o int) Model { - m.horizontalOffset = o - return m -} + // Get selected service name + serviceName := "unknown" + if sel := m.servicesList.SelectedItem(); sel != nil { + if s, ok := sel.(serviceItem); ok { + serviceName = s.info.Name + } + } -func (m Model) WrapMode() bool { - return m.wrapMode -} + // Create log file + timestamp := time.Now().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("docker-%s-%s.log", serviceName, timestamp) + m.recordingPath = filepath.Join(logDir, filename) -func (m Model) SetWrapMode(w bool) Model { - m.wrapMode = w - if w { - m.horizontalOffset = 0 + file, err := os.Create(m.recordingPath) + if err != nil { + return m } + + m.recordingFile = file + m.isRecording = true + + // Write header + m.recordingFile.WriteString(fmt.Sprintf("# Docker Log Recording: %s\n", serviceName)) + m.recordingFile.WriteString(fmt.Sprintf("# Started: %s\n\n", time.Now().Format(time.RFC3339))) + return m } -func (m Model) ToggleWrapMode() Model { - return m.SetWrapMode(!m.wrapMode) -} +func (m Model) StopRecording() Model { + if !m.isRecording { + return m + } -func (m Model) DockerHealth() infra.DockerHealth { - return m.dockerHealth -} + if m.recordingFile != nil { + m.recordingFile.WriteString(fmt.Sprintf("\n# Stopped: %s\n", time.Now().Format(time.RFC3339))) + m.recordingFile.Close() + m.recordingFile = nil + } -func (m Model) LogLines() []string { - return m.logLines + m.isRecording = false + return m } -func (m Model) MaxLineWidth() int { - return m.maxLineWidth +func (m Model) ToggleRecording() Model { + if m.isRecording { + return m.StopRecording() + } + return m.StartRecording() } -func (m Model) Width() int { - return m.width +func (m Model) IsRecording() bool { + return m.isRecording } -func (m Model) Height() int { - return m.height +func (m Model) RecordingPath() string { + return m.recordingPath } -func (m Model) Viewport() viewport.Model { - return m.viewport -} +// Getters +func (m Model) Focus() FocusPanel { return m.focus } +func (m Model) SetFocus(f FocusPanel) Model { m.focus = f; return m } +func (m Model) Width() int { return m.width } +func (m Model) Height() int { return m.height } +func (m Model) Services() []infra.ContainerInfo { return m.services } +func (m Model) Images() []infra.ImageInfo { return m.images } +func (m Model) LogLines() []string { return m.logLines } +func (m Model) Viewport() viewport.Model { return m.viewport } +func (m Model) ServicesList() list.Model { return m.servicesList } +func (m Model) ImagesList() list.Model { return m.imagesList } +func (m Model) FollowMode() bool { return m.followMode } +func (m Model) LogLevelFilter() string { return m.logLevelFilter } func (m Model) SetViewport(vp viewport.Model) Model { m.viewport = vp return m } -func (m Model) ContainerCount() int { - return len(m.dockerHealth.Containers) -} - -func (m Model) SelectedContainer() *infra.ContainerInfo { - if m.containerCursor >= 0 && m.containerCursor < len(m.dockerHealth.Containers) { - return &m.dockerHealth.Containers[m.containerCursor] - } - return nil +func (m Model) SetServicesList(l list.Model) Model { + m.servicesList = l + return m } -func (m Model) FollowMode() bool { - return m.followMode +func (m Model) SetImagesList(l list.Model) Model { + m.imagesList = l + return m } -func (m Model) SetFollowMode(follow bool) Model { - m.followMode = follow - if follow { +func (m Model) SetFollowMode(f bool) Model { + m.followMode = f + if f { m.viewport.GotoBottom() } return m @@ -198,10 +395,6 @@ func (m Model) ToggleFollowMode() Model { return m.SetFollowMode(!m.followMode) } -func (m Model) LogLevelFilter() string { - return m.logLevelFilter -} - func (m Model) SetLogLevelFilter(level string) Model { m.logLevelFilter = level return m @@ -221,27 +414,26 @@ func (m Model) CycleLogLevelFilter() Model { return m } -func (m Model) ShowingActions() bool { - return m.showingActions -} - -func (m Model) SetShowingActions(show bool) Model { - m.showingActions = show - if !show { - m.actionMenuIndex = 0 +// Selected items +func (m Model) SelectedService() *infra.ContainerInfo { + if sel := m.servicesList.SelectedItem(); sel != nil { + if s, ok := sel.(serviceItem); ok { + return &s.info + } } - return m -} - -func (m Model) ActionMenuIndex() int { - return m.actionMenuIndex + return nil } -func (m Model) SetActionMenuIndex(idx int) Model { - m.actionMenuIndex = idx - return m +func (m Model) SelectedImage() *infra.ImageInfo { + if sel := m.imagesList.SelectedItem(); sel != nil { + if i, ok := sel.(imageItem); ok { + return &i.info + } + } + return nil } +// Stats func (m Model) ContainerStats() map[string]ContainerStats { return m.containerStats } @@ -254,11 +446,25 @@ func (m Model) SetContainerStats(name string, stats ContainerStats) Model { return m } -func (m Model) GetSelectedContainerStats() ContainerStats { - if container := m.SelectedContainer(); container != nil { - if stats, ok := m.containerStats[container.Name]; ok { +func (m Model) GetSelectedServiceStats() ContainerStats { + if svc := m.SelectedService(); svc != nil { + if stats, ok := m.containerStats[svc.Name]; ok { return stats } } return ContainerStats{} } + +// Helper +func formatSize(size int64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%d B", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp]) +} diff --git a/internal/tui/tabs/monitor/update.go b/internal/tui/tabs/monitor/update.go index 4a7605c..43fb80a 100644 --- a/internal/tui/tabs/monitor/update.go +++ b/internal/tui/tabs/monitor/update.go @@ -6,19 +6,17 @@ import ( ) type KeyMap struct { - Up key.Binding - Down key.Binding - Tab key.Binding - ScrollLeft key.Binding - ScrollRight key.Binding - ToggleWrap key.Binding - ResetScroll key.Binding - TriggerRCA key.Binding - Follow key.Binding - LogLevel key.Binding - Actions key.Binding - Top key.Binding - Bottom key.Binding + Up key.Binding + Down key.Binding + Tab key.Binding + Follow key.Binding + LogLevel key.Binding + Record key.Binding + Start key.Binding + Stop key.Binding + Restart key.Binding + Top key.Binding + Bottom key.Binding } func DefaultKeyMap() KeyMap { @@ -35,26 +33,6 @@ func DefaultKeyMap() KeyMap { key.WithKeys("tab"), key.WithHelp("Tab", "panel"), ), - ScrollLeft: key.NewBinding( - key.WithKeys("H", "shift+left"), - key.WithHelp("H/L", "scroll"), - ), - ScrollRight: key.NewBinding( - key.WithKeys("L", "shift+right"), - key.WithHelp("", ""), - ), - ToggleWrap: key.NewBinding( - key.WithKeys("ctrl+w"), - key.WithHelp("Ctrl+w", "wrap"), - ), - ResetScroll: key.NewBinding( - key.WithKeys("0"), - key.WithHelp("0", "reset"), - ), - TriggerRCA: key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "AI RCA"), - ), Follow: key.NewBinding( key.WithKeys("f"), key.WithHelp("f", "follow"), @@ -63,9 +41,21 @@ func DefaultKeyMap() KeyMap { key.WithKeys("l"), key.WithHelp("L", "filter"), ), - Actions: key.NewBinding( - key.WithKeys("a", "enter"), - key.WithHelp("a", "actions"), + Record: key.NewBinding( + key.WithKeys("R"), + key.WithHelp("R", "record"), + ), + Start: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "start"), + ), + Stop: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "stop"), + ), + Restart: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "restart"), ), Top: key.NewBinding( key.WithKeys("g"), @@ -78,35 +68,46 @@ func DefaultKeyMap() KeyMap { } } +// Message types +type ContainerActionMsg struct { + Action string + ContainerID string + Success bool + Error error +} + +type RefreshContainersMsg struct{} +type RefreshImagesMsg struct{} + func (m Model) Update(msg tea.Msg, keys KeyMap) (Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.KeyMsg: - if m.showingActions { - return m.handleActionMenu(msg) - } - switch { case key.Matches(msg, keys.Tab): + // Cycle focus: Services → Logs → Images → Stats → Services switch m.focus { - case FocusList: + case FocusServices: m.focus = FocusLogs case FocusLogs: + m.focus = FocusImages + case FocusImages: m.focus = FocusStats case FocusStats: - m.focus = FocusList + m.focus = FocusServices } case key.Matches(msg, keys.Up): switch m.focus { - case FocusList: - if len(m.dockerHealth.Containers) > 0 { - m.containerCursor-- - if m.containerCursor < 0 { - m.containerCursor = len(m.dockerHealth.Containers) - 1 - } - } + case FocusServices: + var cmd tea.Cmd + m.servicesList, cmd = m.servicesList.Update(msg) + cmds = append(cmds, cmd) + case FocusImages: + var cmd tea.Cmd + m.imagesList, cmd = m.imagesList.Update(msg) + cmds = append(cmds, cmd) case FocusLogs: m.viewport.ScrollUp(1) m.followMode = false @@ -114,102 +115,94 @@ func (m Model) Update(msg tea.Msg, keys KeyMap) (Model, tea.Cmd) { case key.Matches(msg, keys.Down): switch m.focus { - case FocusList: - if len(m.dockerHealth.Containers) > 0 { - m.containerCursor = (m.containerCursor + 1) % len(m.dockerHealth.Containers) - } + case FocusServices: + var cmd tea.Cmd + m.servicesList, cmd = m.servicesList.Update(msg) + cmds = append(cmds, cmd) + case FocusImages: + var cmd tea.Cmd + m.imagesList, cmd = m.imagesList.Update(msg) + cmds = append(cmds, cmd) case FocusLogs: m.viewport.ScrollDown(1) } - case key.Matches(msg, keys.ScrollLeft): - if !m.wrapMode && m.focus == FocusLogs { - m.horizontalOffset -= 10 - if m.horizontalOffset < 0 { - m.horizontalOffset = 0 - } - } - - case key.Matches(msg, keys.ScrollRight): - if !m.wrapMode && m.focus == FocusLogs { - maxScroll := m.maxLineWidth - (m.width - 34) - if maxScroll < 0 { - maxScroll = 0 - } - m.horizontalOffset += 10 - if m.horizontalOffset > maxScroll { - m.horizontalOffset = maxScroll - } - } - - case key.Matches(msg, keys.ToggleWrap): - m = m.ToggleWrapMode() - - case key.Matches(msg, keys.ResetScroll): - m.horizontalOffset = 0 - case key.Matches(msg, keys.Follow): m = m.ToggleFollowMode() case key.Matches(msg, keys.LogLevel): m = m.CycleLogLevelFilter() - case key.Matches(msg, keys.Actions): - if m.focus == FocusList && len(m.dockerHealth.Containers) > 0 { - m.showingActions = true - m.actionMenuIndex = 0 - } + case key.Matches(msg, keys.Record): + m = m.ToggleRecording() case key.Matches(msg, keys.Top): - if m.focus == FocusLogs { + switch m.focus { + case FocusLogs: m.viewport.GotoTop() - } else if m.focus == FocusList && len(m.dockerHealth.Containers) > 0 { - m.containerCursor = 0 + case FocusServices: + m.servicesList.Select(0) + case FocusImages: + m.imagesList.Select(0) } case key.Matches(msg, keys.Bottom): - if m.focus == FocusLogs { + switch m.focus { + case FocusLogs: m.viewport.GotoBottom() - } else if m.focus == FocusList && len(m.dockerHealth.Containers) > 0 { - m.containerCursor = len(m.dockerHealth.Containers) - 1 + case FocusServices: + if len(m.services) > 0 { + m.servicesList.Select(len(m.services) - 1) + } + case FocusImages: + if len(m.images) > 0 { + m.imagesList.Select(len(m.images) - 1) + } + } + + case key.Matches(msg, keys.Start): + // Will be handled by parent to call Docker client + if m.focus == FocusServices { + if svc := m.SelectedService(); svc != nil { + return m, func() tea.Msg { + return ContainerActionMsg{ + Action: "start", + ContainerID: svc.ID, + } + } + } } - case key.Matches(msg, keys.TriggerRCA): + case key.Matches(msg, keys.Stop): + if m.focus == FocusServices { + if svc := m.SelectedService(); svc != nil { + return m, func() tea.Msg { + return ContainerActionMsg{ + Action: "stop", + ContainerID: svc.ID, + } + } + } + } + + case key.Matches(msg, keys.Restart): + if m.focus == FocusServices { + if svc := m.SelectedService(); svc != nil { + return m, func() tea.Msg { + return ContainerActionMsg{ + Action: "restart", + ContainerID: svc.ID, + } + } + } + } } } + // Update viewport var vpCmd tea.Cmd m.viewport, vpCmd = m.viewport.Update(msg) cmds = append(cmds, vpCmd) return m, tea.Batch(cmds...) } - -func (m Model) handleActionMenu(msg tea.KeyMsg) (Model, tea.Cmd) { - actionCount := 7 - - switch msg.String() { - case "j", "down": - m.actionMenuIndex = (m.actionMenuIndex + 1) % actionCount - case "k", "up": - m.actionMenuIndex-- - if m.actionMenuIndex < 0 { - m.actionMenuIndex = actionCount - 1 - } - case "esc", "q": - m.showingActions = false - case "enter": - m.showingActions = false - case "s": - m.showingActions = false - case "l": - m.followMode = true - m.showingActions = false - case "r": - m.showingActions = false - case "x": - m.showingActions = false - } - - return m, nil -} diff --git a/internal/tui/tabs/monitor/view.go b/internal/tui/tabs/monitor/view.go index d4bac4e..c3b9e4f 100644 --- a/internal/tui/tabs/monitor/view.go +++ b/internal/tui/tabs/monitor/view.go @@ -8,45 +8,50 @@ import ( "dev-cli/internal/tui/theme" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" ) func (m Model) View() string { - listWidth := 28 + // Left sidebar width + sidebarWidth := 28 if m.width < 100 { - listWidth = 24 + sidebarWidth = 24 } - logWidth := m.width - listWidth - 4 + logWidth := m.width - sidebarWidth - 4 if logWidth < 40 { logWidth = 40 } panelHeight := m.height - 4 - statsHeight := 8 - listHeight := panelHeight - statsHeight - 1 - if listHeight < 10 { - listHeight = 10 + // Calculate heights for left panels + servicesHeight := (panelHeight - 8) / 2 + imagesHeight := (panelHeight - 8) / 2 + statsHeight := 6 + + if servicesHeight < 5 { + servicesHeight = 5 + } + if imagesHeight < 5 { + imagesHeight = 5 } - containerList := m.renderContainerList(listWidth, listHeight) - statsPanel := m.renderStatsPanel(listWidth, statsHeight) - leftColumn := lipgloss.JoinVertical(lipgloss.Left, containerList, statsPanel) + // Render left column panels + servicesPanel := m.renderServicesPanel(sidebarWidth, servicesHeight) + imagesPanel := m.renderImagesPanel(sidebarWidth, imagesHeight) + statsPanel := m.renderStatsPanel(sidebarWidth, statsHeight) - logsPanel := m.renderLogViewport(logWidth, panelHeight) + leftColumn := lipgloss.JoinVertical(lipgloss.Left, servicesPanel, imagesPanel, statsPanel) - if m.showingActions { - menu := m.renderActionMenu() - return lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, logsPanel) + "\n" + menu - } + // Render logs panel + logsPanel := m.renderLogsPanel(logWidth, panelHeight) return lipgloss.JoinHorizontal(lipgloss.Top, leftColumn, logsPanel) } -func (m Model) renderContainerList(width, height int) string { +func (m Model) renderServicesPanel(width, height int) string { borderColor := theme.Surface2 - if m.focus == FocusList { + if m.focus == FocusServices { borderColor = theme.Mauve } @@ -64,73 +69,61 @@ func (m Model) renderContainerList(width, height int) string { countStyle := lipgloss.NewStyle(). Foreground(theme.Overlay0) - containerCount := len(m.dockerHealth.Containers) - posIndicator := "" - if containerCount > 0 { - posIndicator = countStyle.Render(fmt.Sprintf(" [%d/%d]", m.containerCursor+1, containerCount)) + header := headerStyle.Render("⬢ Services") + if len(m.services) > 0 { + header += countStyle.Render(fmt.Sprintf(" [%d]", len(m.services))) } - header := headerStyle.Render("⬢ Containers") + posIndicator - var content strings.Builder content.WriteString(header + "\n") - if !m.dockerHealth.Available { - content.WriteString(lipgloss.NewStyle(). - Foreground(theme.Red). - Padding(1). - Render("✗ Docker unavailable")) - } else if containerCount == 0 { - content.WriteString(lipgloss.NewStyle(). + if len(m.services) == 0 { + noItems := lipgloss.NewStyle(). Foreground(theme.Overlay0). - Padding(1). - Render("No containers")) + Render("No services running") + content.WriteString(noItems) } else { - rows := make([][]string, containerCount) - for i, c := range m.dockerHealth.Containers { - status := lipgloss.NewStyle().Foreground(theme.Green).Render("●") - if c.State != "running" { - status = lipgloss.NewStyle().Foreground(theme.Red).Render("○") - } + content.WriteString(m.servicesList.View()) + } - name := c.Name - maxWidth := width - 10 - if len(name) > maxWidth && maxWidth > 0 { - name = name[:maxWidth-1] + "…" - } + return panelStyle.Render(content.String()) +} - cursor := " " - if i == m.containerCursor { - cursor = "›" - } +func (m Model) renderImagesPanel(width, height int) string { + borderColor := theme.Surface2 + if m.focus == FocusImages { + borderColor = theme.Mauve + } - rows[i] = []string{cursor, status, name} - } + panelStyle := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(borderColor). + Width(width). + Height(height). + MaxHeight(height) + + headerStyle := lipgloss.NewStyle(). + Foreground(theme.Lavender). + Bold(true) + + countStyle := lipgloss.NewStyle(). + Foreground(theme.Overlay0) + + header := headerStyle.Render("📦 Images") + if len(m.images) > 0 { + header += countStyle.Render(fmt.Sprintf(" [%d]", len(m.images))) + } + + var content strings.Builder + content.WriteString(header + "\n") - t := table.New(). - Border(lipgloss.HiddenBorder()). - Width(width - 4). - Rows(rows...). - StyleFunc(func(row, col int) lipgloss.Style { - baseStyle := lipgloss.NewStyle() - if row == m.containerCursor { - baseStyle = baseStyle.Background(theme.Surface1).Bold(true) - } - switch col { - case 0: - return baseStyle.Foreground(theme.Mauve).Width(2) - case 1: - return baseStyle.Width(2) - case 2: - if row == m.containerCursor { - return baseStyle.Foreground(theme.Lavender) - } - return baseStyle.Foreground(theme.Text) - } - return baseStyle - }) - - content.WriteString(t.Render()) + if len(m.images) == 0 { + noItems := lipgloss.NewStyle(). + Foreground(theme.Overlay0). + Render("No images") + content.WriteString(noItems) + } else { + content.WriteString(m.imagesList.View()) } return panelStyle.Render(content.String()) @@ -156,11 +149,11 @@ func (m Model) renderStatsPanel(width, height int) string { var content strings.Builder content.WriteString(headerStyle.Render("▣ Stats") + "\n") - stats := m.GetSelectedContainerStats() - + stats := m.GetSelectedServiceStats() labelStyle := lipgloss.NewStyle().Foreground(theme.Overlay0).Width(4) - content.WriteString(labelStyle.Render("CPU ")) + // CPU sparkline + content.WriteString(labelStyle.Render("CPU ")) sparkWidth := width - 12 if sparkWidth < 5 { sparkWidth = 5 @@ -177,6 +170,7 @@ func (m Model) renderStatsPanel(width, height int) string { } content.WriteString("\n") + // Memory bar content.WriteString(labelStyle.Render("MEM ")) if stats.MemTotal > 0 { memBar := components.NewProgressBar(stats.MemUsed, stats.MemTotal). @@ -188,6 +182,7 @@ func (m Model) renderStatsPanel(width, height int) string { } content.WriteString("\n") + // Network content.WriteString(labelStyle.Render("NET ")) netStyle := lipgloss.NewStyle().Foreground(theme.Overlay0) if stats.NetIn > 0 || stats.NetOut > 0 { @@ -200,7 +195,7 @@ func (m Model) renderStatsPanel(width, height int) string { return panelStyle.Render(content.String()) } -func (m Model) renderLogViewport(width, height int) string { +func (m Model) renderLogsPanel(width, height int) string { borderColor := theme.Surface2 if m.focus == FocusLogs { borderColor = theme.Mauve @@ -222,16 +217,28 @@ func (m Model) renderLogViewport(width, height int) string { Foreground(theme.Overlay0) header := headerStyle.Render("≡ Logs") - if m.dockerHealth.Available && len(m.dockerHealth.Containers) > 0 { - if m.containerCursor >= 0 && m.containerCursor < len(m.dockerHealth.Containers) { - containerName := m.dockerHealth.Containers[m.containerCursor].Name - if len(containerName) > 15 { - containerName = containerName[:12] + "…" - } - header += dimStyle.Render(" (" + containerName + ")") + + // Show selected service name + if svc := m.SelectedService(); svc != nil { + serviceName := svc.Name + if len(serviceName) > 15 { + serviceName = serviceName[:12] + "…" } + header += dimStyle.Render(" (" + serviceName + ")") + } + + // Recording indicator + if m.isRecording { + recBadge := lipgloss.NewStyle(). + Background(theme.Red). + Foreground(theme.Crust). + Bold(true). + Padding(0, 1). + Render("REC") + header += " " + recBadge } + // Follow mode indicator if m.followMode { followBadge := lipgloss.NewStyle(). Background(theme.Green). @@ -241,6 +248,7 @@ func (m Model) renderLogViewport(width, height int) string { header += " " + followBadge } + // Log level filter indicator if m.logLevelFilter != "" { filterBadge := lipgloss.NewStyle(). Background(theme.Surface0). @@ -277,7 +285,7 @@ func (m Model) renderLogViewport(width, height int) string { } } else { displayLines = append(displayLines, dimStyle.Render("No logs available")) - displayLines = append(displayLines, dimStyle.Render("Select a container to view logs")) + displayLines = append(displayLines, dimStyle.Render("Select a service to view logs")) } var contentBuilder strings.Builder @@ -287,19 +295,6 @@ func (m Model) renderLogViewport(width, height int) string { return panelStyle.Render(contentBuilder.String()) } -func truncateLine(line string, maxWidth int) string { - if maxWidth <= 0 { - return "" - } - - runes := []rune(line) - if len(runes) <= maxWidth { - return line - } - - return string(runes[:maxWidth-1]) + "…" -} - func (m Model) filterLogLines() []string { if m.logLevelFilter == "" { return m.logLines @@ -328,26 +323,17 @@ func (m Model) filterLogLines() []string { return filtered } -func (m Model) renderActionMenu() string { - container := m.SelectedContainer() - title := "Actions" - if container != nil { - title = container.Name +func truncateLine(line string, maxWidth int) string { + if maxWidth <= 0 { + return "" } - items := []components.ActionMenuItem{ - {Key: "s", Label: "shell"}, - {Key: "l", Label: "logs (follow)"}, - {Key: "r", Label: "restart"}, - {Key: "x", Label: "stop"}, - {Key: "i", Label: "inspect"}, + runes := []rune(line) + if len(runes) <= maxWidth { + return line } - menu := components.NewActionMenu(title, items...). - SetSelected(m.actionMenuIndex). - SetWidth(24) - - return menu.Render() + return string(runes[:maxWidth-1]) + "…" } func formatBytes(b int64) string {