├── .air.toml ├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── build.sh ├── frontend ├── .env.example ├── .gitignore ├── .vscode │ ├── extensions.json │ └── launch.json ├── astro.config.mjs ├── dist │ ├── favicon.ico │ └── index.html ├── main.go ├── package-lock.json ├── package.json ├── public │ └── favicon.ico ├── src │ ├── assets │ │ └── octocat.svg │ ├── components │ │ ├── Alpine.astro │ │ ├── Footer.astro │ │ ├── JS.astro │ │ ├── Layout.astro │ │ ├── Nav.astro │ │ ├── Viewer.astro │ │ └── partials │ │ │ └── viewer │ │ │ ├── InputDepth.astro │ │ │ ├── Inputs.astro │ │ │ └── Table.astro │ ├── env.d.ts │ └── pages │ │ └── index.astro ├── tailwind.config.mjs └── tsconfig.json ├── go.mod ├── go.sum ├── gol.go ├── install.sh └── pkg ├── api.go ├── api_handler.go ├── api_handler_test.go ├── assets_handler.go ├── docker.go ├── echo.go ├── files.go ├── files_test.go ├── globals.go ├── log.go ├── responses.go ├── slices.go ├── ssh.go ├── strings.go ├── strings_test.go ├── system.go ├── system_test.go ├── types.go ├── validator.go ├── watcher.go └── watcher_test.go /.air.toml: -------------------------------------------------------------------------------- 1 | # .air.conf 2 | # Config file for [Air](https://github.com/cosmtrek/air) in TOML format 3 | 4 | # Working directory 5 | # . or absolute path, please note that the directories following must be under root. 6 | root = "." 7 | tmp_dir = "tmp" 8 | 9 | [build] 10 | # Just plain old shell command. You could use `make` as well. 11 | cmd = "go mod tidy && go build -o ./tmp/main frontend/main.go" 12 | # Binary file yields from `cmd`. 13 | bin = "tmp/main" 14 | # Customize binary. 15 | # allow cors for astro 16 | full_bin = "PP_USER=air ./tmp/main --cors=4321 --open=false -f=testdata/*log -f=testdata/*gz" 17 | # Watch these filename extensions. 18 | include_ext = ["go", "tpl", "tmpl", "html", "env", "conf"] 19 | # Ignore these filename extensions or directories. 20 | exclude_dir = ["assets", "tmp", "vendor", "dist", "frontend"] 21 | # Watch these directories if you specified. 22 | include_dir = [] 23 | # Exclude files. 24 | exclude_file = [] 25 | # It's not necessary to trigger build each time file changes if it's too frequent. 26 | delay = 1000 # ms 27 | # Stop to run old binary when build errors occur. 28 | stop_on_error = true 29 | # This log file places in your tmp_dir. 30 | log = "air_errors.log" 31 | 32 | [log] 33 | # Show log time 34 | time = false 35 | 36 | [color] 37 | # Customize each part's color. If no color found, use the raw app log. 38 | main = "magenta" 39 | watcher = "cyan" 40 | build = "yellow" 41 | runner = "green" 42 | 43 | [misc] 44 | # Delete tmp directory on exit 45 | clean_on_exit = true -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v3 15 | with: 16 | fetch-depth: 0 17 | - uses: kevincobain2000/action-gobrew@v2 18 | with: 19 | version: 'mod' 20 | 21 | - name: Setup Node.js ${{ matrix.node-versions }} 22 | uses: actions/setup-node@v2 23 | with: 24 | node-version: 20 25 | 26 | - name: Build Dist for Embed 27 | working-directory: frontend 28 | run: | 29 | npm install 30 | npm run build 31 | 32 | - name: Run GoReleaser 33 | uses: goreleaser/goreleaser-action@v5 34 | with: 35 | distribution: goreleaser 36 | version: latest 37 | args: release --clean --skip-validate 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | push: 4 | tags-ignore: 5 | - '**' 6 | branches: 7 | - '**' 8 | 9 | name: "Tests" 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} 13 | jobs: 14 | tests: 15 | strategy: 16 | matrix: 17 | go-version: [latest] 18 | os: [ubuntu-latest] 19 | runs-on: ${{ matrix.os }} 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: kevincobain2000/action-gobrew@v2 23 | with: 24 | version: ${{ matrix.go-version }} 25 | - name: Setup Node.js ${{ matrix.node-versions }} 26 | uses: actions/setup-node@v2 27 | with: 28 | node-version: 20 29 | 30 | - name: Install Tools 31 | run: | 32 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 33 | curl -sLk https://raw.githubusercontent.com/kevincobain2000/cover-totalizer/master/install.sh | sh 34 | - name: Setup Node.js ${{ matrix.node-versions }} 35 | uses: actions/setup-node@v2 36 | with: 37 | node-version: 20 38 | - uses: shogo82148/actions-setup-mysql@v1 39 | with: 40 | mysql-version: "8.0" 41 | 42 | - run: cd frontend; npm install 43 | - run: cd frontend; npm run check 44 | 45 | - run: cd frontend; npm run build 46 | - run: go mod tidy;go build -ldflags '-s -w' -o gol frontend/main.go 47 | - run: go mod tidy;go build 48 | - run: golangci-lint run ./... 49 | - run: go test -race -v ./... -count=1 -coverprofile=coverage.out 50 | 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | # diskcache directory 14 | cache/* 15 | vendor/* 16 | 17 | tmp/ 18 | 19 | .env 20 | *.pid 21 | *.log 22 | *.db 23 | .DS_Store 24 | main 25 | gol 26 | testdata/ 27 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: true 3 | # Enable specific linter 4 | # https://golangci-lint.run/usage/linters/#enabled-by-default 5 | enable: 6 | - errcheck 7 | - gosimple 8 | - govet 9 | - ineffassign 10 | - staticcheck 11 | - dupl 12 | - errorlint 13 | - copyloopvar 14 | - goconst 15 | - gocritic 16 | - gocyclo 17 | - goprintffuncname 18 | - gosec 19 | - prealloc 20 | - revive 21 | - stylecheck 22 | - whitespace -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - 6 | main: frontend/main.go 7 | env: 8 | - CGO_ENABLED=0 9 | goos: 10 | - linux 11 | - darwin 12 | # - windows 13 | - freebsd 14 | goarch: 15 | - amd64 16 | - arm64 17 | - arm 18 | ignore: 19 | - goos: windows 20 | goarch: arm64 21 | - goos: freebsd 22 | goarch: arm64 23 | - goos: windows 24 | goarch: arm 25 | - goos: freebsd 26 | goarch: arm 27 | - goos: darwin 28 | goarch: arm 29 | archives: 30 | - 31 | format: binary 32 | name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" 33 | snapshot: 34 | name_template: "{{ incpatch .Version }}-next" 35 | changelog: 36 | sort: asc 37 | filters: 38 | exclude: 39 | - '^docs:' 40 | - '^test:' 41 | - '^bin' 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT Non AI License 2 | 3 | Copyright (c) 2023 Pulkit Kathuria 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | gol 4 | 5 |

6 | 7 |

8 | Logs Viewer 9 |

10 | 11 |

12 | View realtime logs in your fav browser
13 | Advanced regex search
14 | Low Mem Footprint
15 | Single binary 16 |

17 | 18 |

19 | Supports 20 |

21 | 22 |

23 | Docker Container logs from path
24 | Docker Container logs
25 | SSH remote logs
26 | STDIN logs
27 | Local logs
28 | Tar logs
29 |

30 | 31 | - **Quick Setup:** One command to install and run. 32 | 33 | - **Hassle Free:** Doesn't require elastic search or other shebang. 34 | 35 | - **Platform:** Supports (arm64, arch64, Mac, Mac M1, Ubuntu and Windows). 36 | 37 | - **Flexible:** View docker logs, remote logs over ssh, files on disk and piped inputs in browser. 38 | 39 | - **Intelligent** Smartly judges log level, and dates. 40 | 41 | - **Search** Fast search with regex. 42 | 43 | - **Realtime** Tail logs in real time in browser. 44 | 45 | - **Log Rotation** Supports log rotation and watch for new log files. 46 | 47 | - **Embed in GO** Easily embed in your existing Go app. 48 | 49 |

50 | View in Browser 51 |

52 | 53 |

54 | Intuitive UI to view logs in browser 55 |

56 | 57 |

58 | 59 | gol 60 | 61 |

62 | 63 | ### Install using curl 64 | 65 | Use this method if go is not installed on your server 66 | 67 | ```bash 68 | curl -sL https://raw.githubusercontent.com/kevincobain2000/gol/master/install.sh | sh 69 | ``` 70 | 71 | ## Examples 72 | 73 | ### CLI - Basic Example 74 | 75 | ```sh 76 | # run in current directory for pattern 77 | gol "*log" "access/*log.tar.gz" 78 | ``` 79 | 80 | ### CLI - Advanced Examples 81 | 82 | All patterns work in combination with each other. 83 | 84 | ```sh 85 | # search using pipe and file patterns 86 | demsg | gol -f="/var/log/*.log" 87 | 88 | # over ssh 89 | # port optional (default 22), password optional (default ''), private_key optional (default $HOME/.ssh/id_rsa) 90 | gol -s="user@host[:port] [password=/path/to/password] [private_key=/path/to/key] /app/*logs" 91 | 92 | # Docker all container logs 93 | gol -d="" 94 | 95 | # Docker specific container logs 96 | gol -d="container-id" 97 | 98 | # Docker specific path on a container 99 | gol -d="container-id /app/logs.log" 100 | 101 | # All patterns combined 102 | gol -d="container-id" \ 103 | -d="container-id /app/logs.log" \ 104 | -s="user@host[:port] [password=/path/to/password] [private_key=/path/to/key] /app/*logs" \ 105 | -f="/var/log/*.log" 106 | ``` 107 | 108 | ### Embed in GO 109 | 110 | If you don't want to use CLI to have seperate port and want to integrate within your existing Go app. 111 | 112 | 113 | ```go 114 | import ( 115 | "fmt" 116 | "net/http" 117 | 118 | "github.com/kevincobain2000/gol" 119 | ) 120 | 121 | func main() { 122 | // init with options of file path you want to watch 123 | g := gol.NewGol(func(o *gol.GolOptions) error { 124 | o.FilePaths = []string{"*.log"} 125 | return nil 126 | }) 127 | 128 | // register following two routes 129 | http.HandleFunc("/gol/api", g.Adapter(g.NewAPIHandler().Get)) 130 | http.HandleFunc("/gol", g.Adapter(g.NewAssetsHandler().Get)) 131 | 132 | // start server as usual 133 | http.ListenAndServe("localhost:8080", nil) 134 | } 135 | ``` 136 | 137 | ## CHANGE LOG 138 | 139 | - **v1.0.0** - Initial release. 140 | - **v1.0.3** - Multiple file patterns, and pipe input support. 141 | - **v1.0.4** - Support os.Args for quick view. 142 | - **v1.0.5** - Support ssh logs. 143 | - **v1.0.6** - UI shows grouped output. 144 | - **v1.0.7** - Support docker logs. 145 | - **v1.0.14** - Sleak UI changes and support dates. 146 | - **v1.0.17** - Support both ignore and include patterns. 147 | - **v1.0.21** - Better logging. 148 | - **v1.0.22** - Support UA. 149 | - **v1.0.24** - Dropdown on files. 150 | - **v1.0.25** - Searchable files. 151 | - **v1.1.0** - Embed in GO, buggy. 152 | - **v1.1.1** - Embed in GO, stable. 153 | - **v1.1.2** - Go VUP 154 | - **v1.1.3** - Node VUP and debounce for better performance. 155 | 156 | ## Limitations 157 | 158 | - **Docker Logs:** Only supports logs from containers running on the same machine. 159 | - **fmt, stdout:** For embedded use, fmt and stdout logs are not intercepted. 160 | 161 | **Tip:** If you want to capture, then run your app by piping output as `./app >> logs.log`. 162 | 163 | 164 | ## Development Notes 165 | 166 | ```sh 167 | # Get some fake logs 168 | mkdir -p testdata 169 | while true; do date >> testdata/test.log; sleep 1; done 170 | 171 | # Start the API 172 | cd frontend 173 | go run main.go --cors=4321 --open=false -f="../testdata/*log" 174 | # API development on http://localhost:3003/api 175 | 176 | # Start the frontend 177 | npm install 178 | npm run dev 179 | # Frontend development on http://localhost:4321/ 180 | ``` -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | cd frontend 4 | npm install 5 | npm run build 6 | 7 | go build main.go 8 | 9 | cd .. 10 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | # envs for import.meta.env.PUBLIC_PATH; -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # generated types 2 | .astro/ 3 | 4 | # dependencies 5 | node_modules/ 6 | 7 | # logs 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | pnpm-debug.log* 12 | 13 | # environment variables 14 | .env 15 | .env.production 16 | 17 | # macOS-specific files 18 | .DS_Store 19 | -------------------------------------------------------------------------------- /frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /frontend/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /frontend/astro.config.mjs: -------------------------------------------------------------------------------- 1 | import astroSingleFile from 'astro-single-file' 2 | import { defineConfig } from 'astro/config' 3 | import tailwind from '@astrojs/tailwind' 4 | 5 | // https://astro.build/config 6 | export default defineConfig({ 7 | integrations: [astroSingleFile(), tailwind()] 8 | }) 9 | -------------------------------------------------------------------------------- /frontend/dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincobain2000/gol/1228ddbdb7d45f09bb14d7a2eafb8ca119918f69/frontend/dist/favicon.ico -------------------------------------------------------------------------------- /frontend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "flag" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "strings" 10 | 11 | "github.com/kevincobain2000/gol/pkg" 12 | ) 13 | 14 | //go:embed all:dist/* 15 | var publicDir embed.FS 16 | 17 | type Flags struct { 18 | host string 19 | port int64 20 | cors int64 21 | every int64 22 | limit int 23 | baseURL string 24 | filePaths pkg.SliceFlags 25 | sshPaths pkg.SliceFlags 26 | dockerPaths pkg.SliceFlags 27 | access bool 28 | open bool 29 | version bool 30 | } 31 | 32 | var f Flags 33 | 34 | var version = "dev" 35 | 36 | func main() { 37 | pkg.SetupLoggingStdout(slog.LevelInfo) 38 | flags() 39 | 40 | if pkg.IsInputFromPipe() { 41 | pkg.HandleStdinPipe() 42 | } 43 | setFilePaths() 44 | 45 | go pkg.WatchFilePaths(f.every, f.filePaths, f.sshPaths, f.dockerPaths, f.limit) 46 | slog.Info("Flags", "host", f.host, "port", f.port, "baseURL", f.baseURL, "open", f.open, "cors", f.cors, "access", f.access) 47 | 48 | if f.open { 49 | pkg.OpenBrowser(fmt.Sprintf("http://%s:%d%s", f.host, f.port, f.baseURL)) 50 | } 51 | defer pkg.Cleanup() 52 | pkg.HandleCltrC(pkg.Cleanup) 53 | 54 | err := pkg.NewEcho(func(o *pkg.EchoOptions) error { 55 | o.Host = f.host 56 | o.Port = f.port 57 | o.Cors = f.cors 58 | o.Access = f.access 59 | o.BaseURL = f.baseURL 60 | o.PublicDir = &publicDir 61 | return nil 62 | }) 63 | if err != nil { 64 | slog.Error("starting echo", "echo", err) 65 | return 66 | } 67 | } 68 | 69 | func setFilePaths() { 70 | // convenient method support for gol *logs 71 | if len(os.Args) > 1 { 72 | filePaths := pkg.SliceFlags{} 73 | for _, arg := range os.Args[1:] { 74 | // ignore background process flag 75 | if arg == "&" { 76 | continue 77 | } 78 | // Check if the argument is a flag (starts with '-') 79 | if strings.HasPrefix(arg, "-") { 80 | // If a flag is found, reset filePaths to an empty slice and break the loop 81 | filePaths = []string{} 82 | break 83 | } 84 | // Append argument to filePaths if it's not a flag 85 | filePaths = append(filePaths, arg) 86 | } 87 | // If filePaths is not empty, set f.filePaths to filePaths 88 | if len(filePaths) > 0 { 89 | f.filePaths = filePaths 90 | } 91 | } 92 | 93 | // Append GlobalPipeTmpFilePath to f.filePaths if it's not empty 94 | // should be set if user has piped input 95 | if pkg.GlobalPipeTmpFilePath != "" { 96 | f.filePaths = append(f.filePaths, pkg.GlobalPipeTmpFilePath) 97 | } 98 | 99 | // If f.sshPaths is not nil, process each SSH path 100 | if f.sshPaths != nil { 101 | for _, sshPath := range f.sshPaths { 102 | // Convert SSH path string to SSHPathConfig 103 | sshFilePathConfig, err := pkg.StringToSSHPathConfig(sshPath) 104 | if err != nil { 105 | slog.Error("parsing SSH path", sshPath, err) 106 | continue 107 | } 108 | if sshFilePathConfig != nil { 109 | sshConfig := pkg.SSHConfig{ 110 | Host: sshFilePathConfig.Host, 111 | Port: sshFilePathConfig.Port, 112 | User: sshFilePathConfig.User, 113 | Password: sshFilePathConfig.Password, 114 | PrivateKeyPath: sshFilePathConfig.PrivateKeyPath, 115 | } 116 | // Get file information from the SSH path and append to GlobalFilePaths 117 | fileInfos := pkg.GetFileInfos(sshFilePathConfig.FilePath, f.limit, true, &sshConfig) 118 | pkg.GlobalFilePaths = append(pkg.GlobalFilePaths, fileInfos...) 119 | } 120 | } 121 | } 122 | 123 | // Update global file paths with the current filePaths, stdin to tmp, sshPaths, and dockerPaths 124 | pkg.UpdateGlobalFilePaths(f.filePaths, f.sshPaths, f.dockerPaths, f.limit) 125 | } 126 | 127 | func flags() { 128 | flag.Var(&f.filePaths, "f", "full path pattern to the log file") 129 | flag.Var(&f.sshPaths, "s", "full ssh path pattern to the log file") 130 | flag.Var(&f.dockerPaths, "d", "docker paths to the log file") 131 | flag.BoolVar(&f.version, "version", false, "") 132 | flag.BoolVar(&f.access, "access", false, "print access logs") 133 | flag.StringVar(&f.host, "host", "localhost", "host to serve") 134 | flag.Int64Var(&f.port, "port", 3003, "port to serve") 135 | flag.Int64Var(&f.every, "every", 10, "check for file paths every n seconds") 136 | flag.IntVar(&f.limit, "limit", 1000, "limit the number of files to read from the file path pattern") 137 | flag.Int64Var(&f.cors, "cors", 0, "cors port to allow the api (for development)") 138 | flag.BoolVar(&f.open, "open", true, "open browser on start") 139 | flag.StringVar(&f.baseURL, "base-url", "/", "base url with slash") 140 | 141 | flag.Parse() 142 | wantsVersion() 143 | } 144 | 145 | func wantsVersion() { 146 | if len(os.Args) != 2 { 147 | return 148 | } 149 | switch os.Args[1] { 150 | case "-v", "--v", "-version", "--version": 151 | fmt.Println(version) 152 | os.Exit(0) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gol", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "check": "astro check", 9 | "build": "astro build", 10 | "preview": "astro preview", 11 | "astro": "astro" 12 | }, 13 | "dependencies": { 14 | "@astrojs/check": "^0.9.3", 15 | "@astrojs/tailwind": "^5.1.0", 16 | "astro": "^4.15.4", 17 | "astro-single-file": "^1.1.0", 18 | "html-minifier-terser": "^7.2.0", 19 | "tailwindcss": "^3.4.10", 20 | "typescript": "^5.5.4" 21 | }, 22 | "optionalDependencies": { 23 | "@rollup/rollup-linux-x64-gnu": "4.21.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kevincobain2000/gol/1228ddbdb7d45f09bb14d7a2eafb8ca119918f69/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/src/assets/octocat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/components/Alpine.astro: -------------------------------------------------------------------------------- 1 | --- 2 | // source from https://unpkg.com/alpinejs 3 | // source from https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js 4 | // doing this to avoid including alpine from unpkg, we all know what happened with pollyfill.io 5 | --- 6 | 7 | 14 | -------------------------------------------------------------------------------- /frontend/src/components/Footer.astro: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 | 6 |
7 | © 2023 8 | - GOL - Log Viewer by  9 | kevincobain2000 13 |
14 | 15 | -------------------------------------------------------------------------------- /frontend/src/components/JS.astro: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 | 71 | -------------------------------------------------------------------------------- /frontend/src/components/Layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title: string; 4 | description: string; 5 | } 6 | 7 | const { title, description } = Astro.props; 8 | --- 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {title} 30 | 31 | 32 | 33 | 36 | 37 | 38 | 39 | 54 | -------------------------------------------------------------------------------- /frontend/src/components/Nav.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import octocat from '@assets/octocat.svg?raw' 3 | --- 4 | 5 |
6 |
7 | 8 | 9 | Log Viewer 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 |
20 |
21 | 22 | Docker, ssh remote, pipe and local logs in one place 23 | 24 |
25 | 28 | -------------------------------------------------------------------------------- /frontend/src/components/Viewer.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Inputs from "@components/partials/viewer/Inputs.astro"; 3 | import Table from "@components/partials/viewer/Table.astro"; 4 | import JS from "@components/JS.astro"; 5 | import Alpine from "@components/Alpine.astro"; 6 | --- 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 178 | 179 | 180 | -------------------------------------------------------------------------------- /frontend/src/components/partials/viewer/InputDepth.astro: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 |
6 | 12 | 18 |

19 | -1 to crawl all links. 0 to only current link. 1 onwards to crawl links 20 | forward depth 21 |

22 |
23 | -------------------------------------------------------------------------------- /frontend/src/components/partials/viewer/Inputs.astro: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 |
6 |
7 |
8 |
9 | Match 10 |
11 | 14 |
15 | 16 |
17 |
18 | Ignore 19 |
20 | 23 |
24 |
25 |
26 | 34 |
35 | 38 | 43 | 44 | 93 | 94 | 95 | 96 |
97 |
98 |
99 |
100 |
101 | 106 |
107 | 111 |
112 |
113 | 223 |
224 |
225 | 226 | 240 | -------------------------------------------------------------------------------- /frontend/src/components/partials/viewer/Table.astro: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | --- 4 | 5 |
6 |
7 | 20 |
21 |
22 | 23 | Updated at 24 | 25 |
26 |
27 | 28 |
29 |
30 |
33 | 34 | 35 | 50 | 51 | 54 | 57 | 60 | 76 | 77 | 78 | 79 | 210 | 211 |
36 | 42 | 48 | LINE No 49 | LevelDeviceTime 61 | 64 | 67 | 72 | 75 |
212 |
213 | 214 | 215 |
216 | 240 |
241 | Page of ( rows) 244 |
245 |
246 | 260 | -------------------------------------------------------------------------------- /frontend/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /frontend/src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "@components/Layout.astro"; 3 | import Nav from "@components/Nav.astro"; 4 | import Viewer from "@components/Viewer.astro"; 5 | import Footer from "@components/Footer.astro"; 6 | --- 7 | 8 | 12 |
13 |
20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /frontend/tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/base", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@components/*": [ 7 | "src/components/*" 8 | ], 9 | "@assets/*": [ 10 | "src/assets/*" 11 | ] 12 | }, 13 | "jsx": "react-jsx", 14 | "jsxImportSource": "react", 15 | }, 16 | "exclude": [ 17 | "src/components/Alpine.astro", 18 | ] 19 | } -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kevincobain2000/gol 2 | 3 | go 1.22.3 4 | 5 | require ( 6 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d 7 | github.com/docker/docker v27.1.1+incompatible 8 | github.com/go-playground/validator v9.31.0+incompatible 9 | github.com/gravwell/gravwell/v3 v3.8.34 10 | github.com/kevincobain2000/go-human-uuid v0.0.0-20240611094029-af83499c2cf0 11 | github.com/labstack/echo/v4 v4.12.0 12 | github.com/lmittmann/tint v1.0.5 13 | github.com/mattn/go-isatty v0.0.20 14 | github.com/mcuadros/go-defaults v1.2.0 15 | github.com/mileusna/useragent v1.3.4 16 | github.com/stretchr/testify v1.9.0 17 | golang.org/x/crypto v0.26.0 18 | ) 19 | 20 | require ( 21 | github.com/Microsoft/go-winio v0.6.2 // indirect 22 | github.com/containerd/log v0.1.0 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/distribution/reference v0.6.0 // indirect 25 | github.com/docker/go-connections v0.5.0 // indirect 26 | github.com/docker/go-units v0.5.0 // indirect 27 | github.com/felixge/httpsnoop v1.0.4 // indirect 28 | github.com/go-logr/logr v1.4.2 // indirect 29 | github.com/go-logr/stdr v1.2.2 // indirect 30 | github.com/go-playground/locales v0.14.1 // indirect 31 | github.com/go-playground/universal-translator v0.18.1 // indirect 32 | github.com/gogo/protobuf v1.3.2 // indirect 33 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 34 | github.com/labstack/gommon v0.4.2 // indirect 35 | github.com/leodido/go-urn v1.4.0 // indirect 36 | github.com/mattn/go-colorable v0.1.13 // indirect 37 | github.com/moby/docker-image-spec v1.3.1 // indirect 38 | github.com/moby/term v0.5.0 // indirect 39 | github.com/morikuni/aec v1.0.0 // indirect 40 | github.com/opencontainers/go-digest v1.0.0 // indirect 41 | github.com/opencontainers/image-spec v1.1.0 // indirect 42 | github.com/pkg/errors v0.9.1 // indirect 43 | github.com/pmezard/go-difflib v1.0.0 // indirect 44 | github.com/valyala/bytebufferpool v1.0.0 // indirect 45 | github.com/valyala/fasttemplate v1.2.2 // indirect 46 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect 47 | go.opentelemetry.io/otel v1.28.0 // indirect 48 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 // indirect 49 | go.opentelemetry.io/otel/metric v1.28.0 // indirect 50 | go.opentelemetry.io/otel/sdk v1.27.0 // indirect 51 | go.opentelemetry.io/otel/trace v1.28.0 // indirect 52 | golang.org/x/net v0.28.0 // indirect 53 | golang.org/x/sys v0.24.0 // indirect 54 | golang.org/x/text v0.17.0 // indirect 55 | golang.org/x/time v0.6.0 // indirect 56 | gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 57 | gopkg.in/yaml.v3 v3.0.1 // indirect 58 | gotest.tools/v3 v3.5.1 // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= 2 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 3 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 4 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 5 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= 6 | github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= 7 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 8 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 9 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= 10 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 14 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 15 | github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= 16 | github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 17 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= 18 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= 19 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 20 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 21 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 22 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 23 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 24 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 25 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 26 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 27 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 28 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 29 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 30 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 31 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 32 | github.com/go-playground/validator v9.31.0+incompatible h1:UA72EPEogEnq76ehGdEDp4Mit+3FDh548oRqwVgNsHA= 33 | github.com/go-playground/validator v9.31.0+incompatible/go.mod h1:yrEkQXlcI+PugkyDjY2bRrL/UBU4f3rvrgkN3V8JEig= 34 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 35 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 36 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 37 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 38 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 39 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 40 | github.com/gravwell/gravwell/v3 v3.8.34 h1:3Cctgw3RAjZBxvm1rUlZybzKPxrVdmC6nt6vlozOMbI= 41 | github.com/gravwell/gravwell/v3 v3.8.34/go.mod h1:FsIn6mNCcY7wEswbhxRpLchB9cF5jjaQIb/V3jh1YOg= 42 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= 43 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= 44 | github.com/kevincobain2000/go-human-uuid v0.0.0-20240611094029-af83499c2cf0 h1:C5U7fm+NMbCaAEz+9xl4BsEhFEPcOYhB7+MhKk39+IE= 45 | github.com/kevincobain2000/go-human-uuid v0.0.0-20240611094029-af83499c2cf0/go.mod h1:pwoguytL8YNxXpKQRE7XrnAstOJlDf7WFO8EUEAYtLI= 46 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 47 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 48 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 49 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 50 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 51 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 52 | github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= 53 | github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= 54 | github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 55 | github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= 56 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 57 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 58 | github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= 59 | github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 60 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 61 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 62 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 63 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 64 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 65 | github.com/mcuadros/go-defaults v1.2.0 h1:FODb8WSf0uGaY8elWJAkoLL0Ri6AlZ1bFlenk56oZtc= 66 | github.com/mcuadros/go-defaults v1.2.0/go.mod h1:WEZtHEVIGYVDqkKSWBdWKUVdRyKlMfulPaGDWIVeCWY= 67 | github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk= 68 | github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= 69 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 70 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 71 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 72 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 73 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 74 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 75 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 76 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 77 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 78 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 79 | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= 80 | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= 81 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 82 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 83 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 84 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 85 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 86 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 87 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 88 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 89 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 90 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 91 | github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 92 | github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 93 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 94 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 95 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= 96 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= 97 | go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= 98 | go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= 99 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= 100 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= 101 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= 102 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= 103 | go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= 104 | go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= 105 | go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= 106 | go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= 107 | go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= 108 | go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= 109 | go.opentelemetry.io/proto/otlp v1.2.0 h1:pVeZGk7nXDC9O2hncA6nHldxEjm6LByfA2aN8IOkz94= 110 | go.opentelemetry.io/proto/otlp v1.2.0/go.mod h1:gGpR8txAl5M03pDhMC79G6SdqNV26naRm/KDsgaHD8A= 111 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 112 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 113 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 114 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 115 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 116 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 117 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 118 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 119 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 120 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 121 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 122 | golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= 123 | golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 124 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 125 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 126 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 127 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 128 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 129 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 130 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 131 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 132 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 133 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 134 | golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= 135 | golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= 136 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 137 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 138 | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= 139 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 140 | golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= 141 | golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 142 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 143 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 144 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 145 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 146 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 147 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 148 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 149 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 150 | google.golang.org/genproto v0.0.0-20240604185151-ef581f913117 h1:HCZ6DlkKtCDAtD8ForECsY3tKuaR+p4R3grlK80uCCc= 151 | google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU= 152 | google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go.mod h1:OimBR/bc1wPO9iV4NC2bpyjy3VnAwZh5EBPQdtaE5oo= 153 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= 154 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= 155 | google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= 156 | google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= 157 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 158 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 159 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 160 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 161 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 162 | gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= 163 | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= 164 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 165 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 166 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= 167 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= 168 | -------------------------------------------------------------------------------- /gol.go: -------------------------------------------------------------------------------- 1 | package gol 2 | 3 | import ( 4 | "embed" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/kevincobain2000/gol/pkg" 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | //go:embed all:frontend/dist/* 13 | var publicDir embed.FS 14 | 15 | type GolOptions struct { // nolint: revive 16 | Every int64 17 | FilePaths []string 18 | LogLevel slog.Leveler 19 | } 20 | type GolOption func(*GolOptions) error // nolint: revive 21 | 22 | type Gol struct { 23 | Options *GolOptions 24 | } 25 | 26 | func NewGol(opts ...GolOption) *Gol { 27 | options := &GolOptions{ 28 | Every: 1000, 29 | LogLevel: slog.LevelInfo, 30 | FilePaths: []string{}, 31 | } 32 | for _, opt := range opts { 33 | err := opt(options) 34 | if err != nil { 35 | return nil 36 | } 37 | } 38 | return &Gol{ 39 | Options: options, 40 | } 41 | } 42 | 43 | func (g *Gol) NewAPIHandler() *pkg.APIHandler { 44 | pkg.UpdateGlobalFilePaths(g.Options.FilePaths, nil, nil, 1000) 45 | go pkg.WatchFilePaths(g.Options.Every, g.Options.FilePaths, nil, nil, 1000) 46 | return pkg.NewAPIHandler() 47 | } 48 | func (*Gol) NewAssetsHandler() *pkg.AssetsHandler { 49 | return pkg.NewAssetsHandler(&publicDir, "frontend/dist", "index.html") 50 | } 51 | 52 | func (*Gol) Adapter(echoHandler echo.HandlerFunc) http.HandlerFunc { 53 | return func(w http.ResponseWriter, r *http.Request) { 54 | e := echo.New() 55 | c := e.NewContext(r, w) 56 | if err := echoHandler(c); err != nil { 57 | e.HTTPErrorHandler(err, c) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$BIN_DIR" ]; then 4 | BIN_DIR=/usr/local/bin 5 | fi 6 | 7 | echo "Installing gol to $BIN_DIR" 8 | 9 | THE_ARCH_BIN='' 10 | THIS_PROJECT_NAME='gol' 11 | 12 | THISOS=$(uname -s) 13 | ARCH=$(uname -m) 14 | DEST=$BIN_DIR/$THIS_PROJECT_NAME 15 | 16 | case $THISOS in 17 | Linux*) 18 | case $ARCH in 19 | arm64) 20 | THE_ARCH_BIN="$THIS_PROJECT_NAME-linux-arm64" 21 | ;; 22 | aarch64) 23 | THE_ARCH_BIN="$THIS_PROJECT_NAME-linux-arm64" 24 | ;; 25 | armv6l) 26 | THE_ARCH_BIN="$THIS_PROJECT_NAME-linux-arm" 27 | ;; 28 | armv7l) 29 | THE_ARCH_BIN="$THIS_PROJECT_NAME-linux-arm" 30 | ;; 31 | *) 32 | THE_ARCH_BIN="$THIS_PROJECT_NAME-linux-amd64" 33 | ;; 34 | esac 35 | ;; 36 | Darwin*) 37 | case $ARCH in 38 | arm64) 39 | THE_ARCH_BIN="$THIS_PROJECT_NAME-darwin-arm64" 40 | ;; 41 | *) 42 | THE_ARCH_BIN="$THIS_PROJECT_NAME-darwin-amd64" 43 | ;; 44 | esac 45 | ;; 46 | Windows|MINGW64_NT*) 47 | THE_ARCH_BIN="$THIS_PROJECT_NAME-windows-amd64.exe" 48 | THIS_PROJECT_NAME="$THIS_PROJECT_NAME.exe" 49 | ;; 50 | esac 51 | 52 | if [ -z "$THE_ARCH_BIN" ]; then 53 | echo "This script is not supported on $THISOS and $ARCH" 54 | exit 1 55 | fi 56 | 57 | 58 | curl -kL --progress-bar https://github.com/kevincobain2000/$THIS_PROJECT_NAME/releases/latest/download/$THE_ARCH_BIN -o $THIS_PROJECT_NAME 59 | echo "Downloaded $THIS_PROJECT_NAME" 60 | chmod +x $THIS_PROJECT_NAME 61 | 62 | SUDO="" 63 | 64 | # check if $DEST is writable and suppress an error message 65 | touch "$DEST" 2>/dev/null 66 | 67 | # we need sudo powers to write to DEST 68 | if [ $? -eq 1 ]; then 69 | echo "You do not have permission to write to $DEST, enter your password to grant sudo powers" 70 | SUDO="sudo" 71 | fi 72 | 73 | $SUDO mv $THIS_PROJECT_NAME "$BIN_DIR" 74 | 75 | echo "Installed successfully to: $DEST" 76 | 77 | -------------------------------------------------------------------------------- /pkg/api.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type API struct { 4 | } 5 | 6 | func NewAPI() *API { 7 | return &API{} 8 | } 9 | 10 | func (a *API) FindSSHConfig(host string) *SSHPathConfig { 11 | for _, sshConfig := range GlobalPathSSHConfig { 12 | if sshConfig.Host == host { 13 | return &sshConfig 14 | } 15 | } 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /pkg/api_handler.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/labstack/echo/v4" 8 | "github.com/mcuadros/go-defaults" 9 | ) 10 | 11 | type APIHandler struct { 12 | API *API 13 | } 14 | type FileInfo struct { 15 | FilePath string `json:"file_path"` 16 | LinesCount int `json:"lines_count"` 17 | FileSize int64 `json:"file_size"` 18 | Name string `json:"name"` 19 | Type string `json:"type"` 20 | Host string `json:"host"` 21 | } 22 | 23 | func NewAPIHandler() *APIHandler { 24 | return &APIHandler{ 25 | API: NewAPI(), 26 | } 27 | } 28 | 29 | type APIRequest struct { 30 | Query string `json:"query" query:"query"` 31 | Ignore string `json:"ignore" query:"ignore"` 32 | FilePath string `json:"file_path" query:"file_path"` 33 | Host string `json:"host" query:"host"` 34 | Type string `json:"type" query:"type"` 35 | Page int `json:"page" query:"page" default:"1" validate:"required,gte=1" message:"page >=1 is required"` 36 | PerPage int `json:"per_page" query:"per_page" default:"15" validate:"required" message:"per_page is required"` 37 | Reverse bool `json:"reverse" query:"reverse" default:"false"` 38 | } 39 | 40 | type APIResponse struct { 41 | Result ScanResult `json:"result"` 42 | FilePaths []FileInfo `json:"file_paths"` 43 | } 44 | 45 | func (h *APIHandler) Get(c echo.Context) error { 46 | req := new(APIRequest) 47 | if err := BindRequest(c, req); err != nil { 48 | return echo.NewHTTPError(http.StatusUnprocessableEntity, err) 49 | } 50 | defaults.SetDefaults(req) 51 | msgs, err := ValidateRequest(req) 52 | if err != nil { 53 | return echo.NewHTTPError(http.StatusUnprocessableEntity, msgs) 54 | } 55 | 56 | if len(GlobalFilePaths) == 0 { 57 | return echo.NewHTTPError(http.StatusNotFound, "filepath not found") 58 | } 59 | 60 | if req.FilePath == "" { 61 | first := GlobalFilePaths[0] 62 | req.FilePath = first.FilePath 63 | req.Host = first.Host 64 | req.Type = first.Type 65 | } 66 | if req.FilePath != "" && req.Type == "" { 67 | return echo.NewHTTPError(http.StatusUnprocessableEntity, "type and host are required") 68 | } 69 | 70 | if !FilePathInGlobalFilePaths(req.FilePath) { 71 | return echo.NewHTTPError(http.StatusNotFound, "file not found") 72 | } 73 | 74 | var watcher *Watcher 75 | if req.Type == TypeDocker { 76 | if !strings.HasPrefix(req.FilePath, TmpContainerPath) { 77 | result, err := ContainerLogsFromFile(req.Host, req.Query, req.Ignore, req.FilePath, req.Page, req.PerPage, req.Reverse) 78 | if err != nil { 79 | return echo.NewHTTPError(http.StatusInternalServerError, err) 80 | } 81 | result.Type = req.Type 82 | return c.JSON(http.StatusOK, APIResponse{ 83 | Result: *result, 84 | FilePaths: GlobalFilePaths, 85 | }) 86 | } 87 | 88 | watcher, err = NewWatcher(req.FilePath, req.Query, req.Ignore, false, "", "", "", "", "") 89 | if err != nil { 90 | return echo.NewHTTPError(http.StatusInternalServerError, err) 91 | } 92 | } 93 | 94 | if req.Type == TypeSSH { 95 | sshConfig := h.API.FindSSHConfig(req.Host) 96 | if sshConfig == nil { 97 | return echo.NewHTTPError(http.StatusNotFound, "ssh config not found") 98 | } 99 | watcher, err = NewWatcher(req.FilePath, req.Query, req.Ignore, true, sshConfig.Host, sshConfig.Port, sshConfig.User, sshConfig.Password, sshConfig.PrivateKeyPath) 100 | if err != nil { 101 | return echo.NewHTTPError(http.StatusInternalServerError, err) 102 | } 103 | } 104 | if req.Type == TypeFile || req.Type == TypeStdin { 105 | watcher, err = NewWatcher(req.FilePath, req.Query, req.Ignore, false, "", "", "", "", "") 106 | if err != nil { 107 | return echo.NewHTTPError(http.StatusInternalServerError, err) 108 | } 109 | } 110 | 111 | result, err := watcher.Scan(req.Page, req.PerPage, req.Reverse) 112 | result.Type = req.Type 113 | result.Host = req.Host 114 | if err != nil { 115 | return echo.NewHTTPError(http.StatusInternalServerError, err) 116 | } 117 | 118 | return c.JSON(http.StatusOK, APIResponse{ 119 | Result: *result, 120 | FilePaths: GlobalFilePaths, 121 | }) 122 | } 123 | -------------------------------------------------------------------------------- /pkg/api_handler_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "testing" 9 | 10 | "github.com/labstack/echo/v4" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestNewAPIHandler(t *testing.T) { 15 | handler := NewAPIHandler() 16 | assert.NotNil(t, handler) 17 | } 18 | 19 | func TestAPIHandler_Get(t *testing.T) { 20 | e := echo.New() 21 | 22 | // Set up global variables for testing 23 | GlobalFilePaths = []FileInfo{ 24 | { 25 | FilePath: "test.log", 26 | LinesCount: 4, 27 | FileSize: 0, 28 | Type: TypeFile, 29 | }, 30 | } 31 | GlobalPipeTmpFilePath = "temp.log" 32 | 33 | // Create a temporary log file for testing 34 | // nolint:goconst 35 | content := `INFO Starting service 36 | ERROR An error occurred 37 | INFO Service running 38 | ERROR Another error occurred` 39 | err := os.WriteFile(GlobalFilePaths[0].FilePath, []byte(content), 0600) 40 | assert.NoError(t, err) 41 | defer os.Remove(GlobalFilePaths[0].FilePath) 42 | 43 | // Create a test request 44 | req := httptest.NewRequest(http.MethodGet, "/api?query=ERROR&page=1&per_page=10", nil) 45 | rec := httptest.NewRecorder() 46 | c := e.NewContext(req, rec) 47 | 48 | // Create the API handler and execute the Get method 49 | handler := NewAPIHandler() 50 | if assert.NoError(t, handler.Get(c)) { 51 | assert.Equal(t, http.StatusOK, rec.Code) 52 | expected := `{ 53 | "result": { 54 | "file_path": "test.log", 55 | "host": "", 56 | "type": "file", 57 | "match_pattern": "ERROR", 58 | "total": 2, 59 | "lines": [ 60 | { 61 | "line_number": 2, 62 | "content": "ERROR An error occurred", 63 | "level": "error", 64 | "date": "", 65 | "agent": { 66 | "device": "server" 67 | } 68 | }, 69 | { 70 | "line_number": 4, 71 | "content": "ERROR Another error occurred", 72 | "level": "error", 73 | "date": "", 74 | "agent": { 75 | "device": "server" 76 | } 77 | } 78 | ] 79 | }, 80 | "file_paths": [ 81 | { 82 | "file_path": "test.log", 83 | "lines_count": 4, 84 | "file_size": 0, 85 | "type": "file", 86 | "host": "", 87 | "name": "" 88 | } 89 | ] 90 | }` 91 | fmt.Println(rec.Body.String()) 92 | assert.JSONEq(t, expected, rec.Body.String()) 93 | } 94 | } 95 | func TestAPIHandler_Get404(t *testing.T) { 96 | e := echo.New() 97 | 98 | // Set up global variables for testing 99 | GlobalFilePaths = []FileInfo{ 100 | { 101 | FilePath: "test.log", 102 | LinesCount: 4, 103 | FileSize: 0, 104 | Type: TypeFile, 105 | }, 106 | } 107 | GlobalPipeTmpFilePath = "temp.log" 108 | 109 | // nolint:goconst 110 | content := `INFO Starting service 111 | ERROR An error occurred 112 | INFO Service running 113 | ERROR Another error occurred` 114 | err := os.WriteFile(GlobalFilePaths[0].FilePath, []byte(content), 0600) 115 | assert.NoError(t, err) 116 | defer os.Remove(GlobalFilePaths[0].FilePath) 117 | 118 | handler := NewAPIHandler() 119 | 120 | req := httptest.NewRequest(http.MethodGet, "/api?file_path=wrong", nil) 121 | rec := httptest.NewRecorder() 122 | c := e.NewContext(req, rec) 123 | resp := handler.Get(c) 124 | 125 | assert.Error(t, resp) 126 | // nolint: errorlint 127 | if he, ok := resp.(*echo.HTTPError); ok { 128 | assert.Equal(t, http.StatusUnprocessableEntity, he.Code) 129 | } else { 130 | assert.Fail(t, "response is not an HTTP error") 131 | } 132 | 133 | req = httptest.NewRequest(http.MethodGet, "/api?file_path=wrong&type=file", nil) 134 | rec = httptest.NewRecorder() 135 | c = e.NewContext(req, rec) 136 | resp = handler.Get(c) 137 | 138 | assert.Error(t, resp) 139 | // nolint: errorlint 140 | if he, ok := resp.(*echo.HTTPError); ok { 141 | assert.Equal(t, http.StatusNotFound, he.Code) 142 | } else { 143 | assert.Fail(t, "response is not an HTTP error") 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /pkg/assets_handler.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | type AssetsHandler struct { 13 | filename string 14 | distDir string 15 | publicDir *embed.FS 16 | } 17 | 18 | func NewAssetsHandler(publicDir *embed.FS, distDir string, filename string) *AssetsHandler { 19 | return &AssetsHandler{ 20 | publicDir: publicDir, 21 | distDir: distDir, 22 | filename: filename, 23 | } 24 | } 25 | 26 | func (h *AssetsHandler) GetPlain(c echo.Context) error { 27 | filename := fmt.Sprintf("%s/%s", h.distDir, h.filename) 28 | content, err := h.publicDir.ReadFile(filename) 29 | if err != nil { 30 | return echo.NewHTTPError(http.StatusNotFound, "Not Found") 31 | } 32 | return ResponsePlain(c, content, "0") 33 | } 34 | func (h *AssetsHandler) GetICO(c echo.Context) error { 35 | filename := fmt.Sprintf("%s/%s", h.distDir, h.filename) 36 | content, err := h.publicDir.ReadFile(filename) 37 | if err != nil { 38 | return echo.NewHTTPError(http.StatusNotFound, "Not Found") 39 | } 40 | SetHeadersResponsePNG(c.Response().Header()) 41 | return c.Blob(http.StatusOK, "image/x-icon", content) 42 | } 43 | 44 | func (h *AssetsHandler) Get(c echo.Context) error { 45 | filename := fmt.Sprintf("%s/%s", h.distDir, h.filename) 46 | content, err := h.publicDir.ReadFile(filename) 47 | if err != nil { 48 | return c.String(http.StatusOK, os.Getenv("VERSION")) 49 | } 50 | return ResponseHTML(c, content, "0") 51 | } 52 | -------------------------------------------------------------------------------- /pkg/docker.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/acarl005/stripansi" 14 | "github.com/docker/docker/api/types" 15 | "github.com/docker/docker/api/types/container" 16 | "github.com/docker/docker/client" 17 | ) 18 | 19 | func ListDockerContainers() ([]types.Container, error) { 20 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to create Docker client: %w", err) 23 | } 24 | 25 | // Get the list of containers 26 | return cli.ContainerList(context.Background(), container.ListOptions{}) 27 | } 28 | 29 | func ContainerStdoutToTmp(containerID string) *os.File { 30 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 31 | if err != nil { 32 | slog.Error("creating Docker client", "docker", err) 33 | return nil 34 | } 35 | 36 | // Get container logs 37 | options := container.LogsOptions{ShowStdout: true, ShowStderr: true} 38 | out, err := cli.ContainerLogs(context.Background(), containerID, options) 39 | if err != nil { 40 | slog.Error("getting container logs", containerID, err) 41 | return nil 42 | } 43 | 44 | // Check if tmpFile already exists in GlobalFilePaths for container ID previously by watcher 45 | var tmpFile *os.File 46 | for _, fileInfo := range GlobalFilePaths { 47 | if fileInfo.Host == containerID[:12] && fileInfo.Type == TypeDocker && strings.HasPrefix(fileInfo.FilePath, TmpContainerPath) { 48 | tmpFile, err = os.OpenFile(fileInfo.FilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 49 | if err != nil { 50 | slog.Error("opening temp file", fileInfo.FilePath, err) 51 | return nil 52 | } 53 | } 54 | } 55 | if tmpFile == nil { 56 | tmpFile, err = os.Create(GetTmpFileNameForContainer()) 57 | if err != nil { 58 | slog.Error("creating temp file", "tmp", err) 59 | return nil 60 | } 61 | } 62 | scanner := bufio.NewScanner(out) 63 | lineCount := 0 64 | for scanner.Scan() { 65 | line := scanner.Text() 66 | line = stripansi.Strip(line) 67 | if lineCount >= 10000 { 68 | if err := tmpFile.Truncate(0); err != nil { 69 | slog.Error("truncating file", "scan", err) 70 | } 71 | if _, err := tmpFile.Seek(0, 0); err != nil { 72 | slog.Error("seeking file", "scan", err) 73 | } 74 | lineCount = 0 75 | } 76 | if _, err := tmpFile.WriteString(line + "\n"); err != nil { 77 | slog.Error("writing to file", "scan", err) 78 | } 79 | lineCount++ 80 | } 81 | 82 | if err := scanner.Err(); err != nil { 83 | slog.Error("reading container logs", containerID, err) 84 | } 85 | return tmpFile 86 | } 87 | 88 | func ContainerLogsFromFile(containerID string, query string, ignorePattern string, filePath string, page, pageSize int, reverse bool) (*ScanResult, error) { 89 | lines := []LineResult{} 90 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 91 | if err != nil { 92 | return nil, fmt.Errorf("failed to create Docker client: %w", err) 93 | } 94 | 95 | re, err := regexp.Compile(query) 96 | if err != nil { 97 | return nil, fmt.Errorf("invalid regex pattern: %w", err) 98 | } 99 | 100 | var reIgnore *regexp.Regexp 101 | if ignorePattern != "" { 102 | reIgnore, err = regexp.Compile(ignorePattern) 103 | if err != nil { 104 | return nil, fmt.Errorf("invalid ignore regex pattern: %w", err) 105 | } 106 | } 107 | 108 | countCmd := []string{"sh", "-c", fmt.Sprintf("wc -l < %s", filePath)} 109 | countExecConfig := container.ExecOptions{ 110 | Cmd: countCmd, 111 | AttachStdout: true, 112 | AttachStderr: true, 113 | } 114 | 115 | countExecIDResp, err := cli.ContainerExecCreate(context.Background(), containerID, countExecConfig) 116 | if err != nil { 117 | return nil, fmt.Errorf("failed to create exec instance for counting lines: %w", err) 118 | } 119 | 120 | countResp, err := cli.ContainerExecAttach(context.Background(), countExecIDResp.ID, container.ExecStartOptions{}) 121 | if err != nil { 122 | return nil, fmt.Errorf("failed to attach to exec instance for counting lines: %w", err) 123 | } 124 | defer countResp.Close() 125 | 126 | countScanner := bufio.NewScanner(countResp.Reader) 127 | countScanner.Scan() 128 | totalLines, err := strconv.Atoi(strings.TrimSpace(CleanString(countScanner.Text()))) 129 | if err != nil { 130 | return nil, fmt.Errorf("failed to parse line count: %w", err) 131 | } 132 | 133 | startLine := (page - 1) * pageSize 134 | 135 | var cmd []string 136 | if reverse { 137 | cmd = []string{"sh", "-c", fmt.Sprintf("tac %s | tail -n +%d | head -n %d", filePath, startLine+1, pageSize)} 138 | } else { 139 | cmd = []string{"sh", "-c", fmt.Sprintf("tail -n +%d %s | head -n %d", startLine+1, filePath, pageSize)} 140 | } 141 | 142 | execConfig := container.ExecOptions{ 143 | Cmd: cmd, 144 | AttachStdout: true, 145 | AttachStderr: true, 146 | } 147 | 148 | execIDResp, err := cli.ContainerExecCreate(context.Background(), containerID, execConfig) 149 | if err != nil { 150 | return nil, fmt.Errorf("failed to create exec instance: %w", err) 151 | } 152 | 153 | resp, err := cli.ContainerExecAttach(context.Background(), execIDResp.ID, container.ExecStartOptions{}) 154 | if err != nil { 155 | return nil, fmt.Errorf("failed to attach to exec instance: %w", err) 156 | } 157 | defer resp.Close() 158 | 159 | scanner := bufio.NewScanner(resp.Reader) 160 | lineNumber := startLine + 1 161 | for scanner.Scan() { 162 | lineContent := stripansi.Strip(scanner.Text()) 163 | lineContent = CleanString(lineContent) 164 | if reIgnore != nil && reIgnore.MatchString(lineContent) { 165 | continue 166 | } 167 | if re.MatchString(lineContent) { 168 | lineResult := LineResult{ 169 | LineNumber: lineNumber, 170 | Content: lineContent, 171 | Level: "", 172 | } 173 | lines = append(lines, lineResult) 174 | } 175 | lineNumber++ 176 | } 177 | if err := scanner.Err(); err != nil { 178 | return nil, fmt.Errorf("reading logs: %w", err) 179 | } 180 | 181 | if reverse { 182 | for i := range lines { 183 | lines[i].LineNumber = totalLines - (startLine + len(lines)) + i + 1 184 | } 185 | shifted := make([]LineResult, len(lines)) 186 | for i := range lines { 187 | newIndex := len(lines) - 1 - i 188 | shifted[newIndex] = LineResult{ 189 | LineNumber: lines[len(lines)-1-i].LineNumber, 190 | Content: lines[i].Content, 191 | Level: lines[len(lines)-1-i].Level, 192 | } 193 | } 194 | lines = shifted 195 | } 196 | 197 | AppendGeneralInfo(&lines) 198 | scanResult := &ScanResult{ 199 | FilePath: filePath, 200 | Host: containerID, 201 | MatchPattern: query, 202 | Total: totalLines, 203 | Lines: lines, 204 | } 205 | 206 | return scanResult, nil 207 | } 208 | 209 | func GetContainerFileInfos(pattern string, limit int, containerID string) []FileInfo { 210 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 211 | if err != nil { 212 | slog.Error("Failed to create Docker client", "docker", err) 213 | return nil 214 | } 215 | 216 | execConfig := container.ExecOptions{ 217 | Cmd: []string{"sh", "-c", fmt.Sprintf("ls -1 %s", pattern)}, 218 | AttachStdout: true, 219 | AttachStderr: true, 220 | } 221 | 222 | execIDResp, err := cli.ContainerExecCreate(context.Background(), containerID, execConfig) 223 | if err != nil { 224 | slog.Error("Failed to create exec instance", "container", err) 225 | return nil 226 | } 227 | 228 | resp, err := cli.ContainerExecAttach(context.Background(), execIDResp.ID, container.ExecStartOptions{}) 229 | if err != nil { 230 | slog.Error("Failed to attach to exec instance", "container", err) 231 | return nil 232 | } 233 | defer resp.Close() 234 | 235 | scanner := bufio.NewScanner(resp.Reader) 236 | filePaths := []string{} 237 | for scanner.Scan() { 238 | filePaths = append(filePaths, CleanString(scanner.Text())) 239 | } 240 | if err := scanner.Err(); err != nil { 241 | slog.Error("reading exec output", "scanner", err) 242 | return nil 243 | } 244 | 245 | fileInfos := make([]FileInfo, 0) 246 | if len(filePaths) > limit { 247 | slog.Warn("Limiting to files", "docker", limit) 248 | filePaths = filePaths[:limit] 249 | } 250 | for _, filePath := range filePaths { 251 | linesCount, fileSize, err := getFileStatsFromContainer(cli, containerID, filePath) 252 | if err != nil { 253 | slog.Error("Failed to get file stats", filePath, err) 254 | continue 255 | } 256 | 257 | fileInfos = append(fileInfos, FileInfo{ 258 | FilePath: filePath, 259 | LinesCount: linesCount, 260 | FileSize: fileSize, 261 | Type: TypeDocker, 262 | Host: containerID[:12], 263 | }) 264 | } 265 | 266 | return fileInfos 267 | } 268 | 269 | func getFileStatsFromContainer(cli *client.Client, containerID string, filePath string) (int, int64, error) { 270 | execConfig := container.ExecOptions{ 271 | Cmd: []string{"wc", "-l", filePath}, 272 | AttachStdout: true, 273 | AttachStderr: true, 274 | } 275 | 276 | execIDResp, err := cli.ContainerExecCreate(context.Background(), containerID, execConfig) 277 | if err != nil { 278 | return 0, 0, fmt.Errorf("failed to create exec instance for wc: %w", err) 279 | } 280 | 281 | resp, err := cli.ContainerExecAttach(context.Background(), execIDResp.ID, container.ExecStartOptions{}) 282 | if err != nil { 283 | return 0, 0, fmt.Errorf("failed to attach to exec instance for wc: %w", err) 284 | } 285 | defer resp.Close() 286 | 287 | scanner := bufio.NewScanner(resp.Reader) 288 | var linesCount int 289 | if scanner.Scan() { 290 | fmt.Sscanf(CleanString(scanner.Text()), "%d", &linesCount) //nolint: errcheck 291 | } 292 | if err := scanner.Err(); err != nil { 293 | return 0, 0, fmt.Errorf("reading wc output: %w", err) 294 | } 295 | 296 | execConfig = container.ExecOptions{ 297 | Cmd: []string{"stat", "-c", "%s", filePath}, 298 | AttachStdout: true, 299 | AttachStderr: true, 300 | } 301 | 302 | execIDResp, err = cli.ContainerExecCreate(context.Background(), containerID, execConfig) 303 | if err != nil { 304 | return 0, 0, fmt.Errorf("failed to create exec instance for stat: %w", err) 305 | } 306 | 307 | resp, err = cli.ContainerExecAttach(context.Background(), execIDResp.ID, container.ExecStartOptions{}) 308 | if err != nil { 309 | return 0, 0, fmt.Errorf("failed to attach to exec instance for stat: %w", err) 310 | } 311 | defer resp.Close() 312 | 313 | scanner = bufio.NewScanner(resp.Reader) 314 | var fileSize int64 315 | if scanner.Scan() { 316 | fmt.Sscanf(CleanString(scanner.Text()), "%d", &fileSize) //nolint: errcheck 317 | } 318 | if err := scanner.Err(); err != nil { 319 | return 0, 0, fmt.Errorf("reading stat output: %w", err) 320 | } 321 | 322 | return linesCount, fileSize, nil 323 | } 324 | -------------------------------------------------------------------------------- /pkg/echo.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/labstack/echo/v4" 11 | "github.com/labstack/echo/v4/middleware" 12 | ) 13 | 14 | type EchoOptions struct { 15 | Host string 16 | Port int64 17 | Cors int64 18 | BaseURL string 19 | Access bool 20 | PublicDir *embed.FS 21 | } 22 | 23 | type EchoOption func(*EchoOptions) error 24 | 25 | func NewEcho(opts ...EchoOption) error { 26 | options := &EchoOptions{ 27 | Cors: 0, 28 | BaseURL: "/", 29 | Host: "localhost", // default host 30 | Port: 3000, // default port 31 | Access: false, 32 | PublicDir: nil, 33 | } 34 | for _, opt := range opts { 35 | err := opt(options) 36 | if err != nil { 37 | return err 38 | } 39 | } 40 | e := echo.New() 41 | 42 | SetupMiddlewares(e) 43 | if options.Access { 44 | e.Use(middleware.Logger()) 45 | } 46 | SetupRoutes(e, options) 47 | SetupCors(e, options) 48 | 49 | e.Logger.Fatal(e.Start(fmt.Sprintf("%s:%d", options.Host, options.Port))) 50 | return nil 51 | } 52 | 53 | func SetupMiddlewares(e *echo.Echo) { 54 | e.HTTPErrorHandler = HTTPErrorHandler 55 | e.Use(middleware.Recover()) 56 | e.Use(middleware.Gzip()) 57 | e.Pre(middleware.RemoveTrailingSlash()) 58 | e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ 59 | Format: ltsv(), 60 | })) 61 | } 62 | 63 | func SetupRoutes(e *echo.Echo, options *EchoOptions) { 64 | e.GET(options.BaseURL+"", NewAssetsHandler(options.PublicDir, "dist", "index.html").Get) 65 | e.GET(options.BaseURL+"favicon.ico", NewAssetsHandler(options.PublicDir, "dist", "favicon.ico").GetICO) 66 | e.GET(options.BaseURL+"api", NewAPIHandler().Get) 67 | } 68 | 69 | func SetupCors(e *echo.Echo, options *EchoOptions) { 70 | if options.Cors == 0 { 71 | return 72 | } 73 | e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 74 | AllowOrigins: []string{fmt.Sprintf("http://localhost:%d", options.Cors)}, 75 | AllowMethods: []string{http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete}, 76 | })) 77 | } 78 | 79 | // HTTPErrorResponse is the response for HTTP errors 80 | type HTTPErrorResponse struct { 81 | Error interface{} `json:"error"` 82 | } 83 | 84 | // HTTPErrorHandler handles HTTP errors for entire application 85 | func HTTPErrorHandler(err error, c echo.Context) { 86 | SetHeadersResponseJSON(c.Response().Header()) 87 | code := http.StatusInternalServerError 88 | var message interface{} 89 | // nolint: errorlint 90 | if he, ok := err.(*echo.HTTPError); ok { 91 | code = he.Code 92 | message = he.Message 93 | } else { 94 | message = err.Error() 95 | } 96 | 97 | if code == http.StatusInternalServerError { 98 | message = fmt.Sprintf("%v", err) 99 | } 100 | if err = c.JSON(code, &HTTPErrorResponse{Error: message}); err != nil { 101 | slog.Error("handling HTTP error", "handler", err) 102 | } 103 | } 104 | 105 | func ltsv() string { 106 | timeCustom := time.Now().Format("2006-01-02 15:04:05") 107 | var format string 108 | format += fmt.Sprintf("time:%s\t", timeCustom) 109 | format += "host:${remote_ip}\t" 110 | format += "forwardedfor:${header:x-forwarded-for}\t" 111 | format += "req:-\t" 112 | format += "status:${status}\t" 113 | format += "method:${method}\t" 114 | format += "uri:${uri}\t" 115 | format += "size:${bytes_out}\t" 116 | format += "referer:${referer}\t" 117 | format += "ua:${user_agent}\t" 118 | format += "reqtime_ns:${latency}\t" 119 | format += "cache:-\t" 120 | format += "runtime:-\t" 121 | format += "apptime:-\t" 122 | format += "vhost:${host}\t" 123 | format += "reqtime_human:${latency_human}\t" 124 | format += "x-request-id:${id}\t" 125 | format += "host:${host}\n" 126 | return format 127 | } 128 | -------------------------------------------------------------------------------- /pkg/files.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "compress/gzip" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log/slog" 11 | "net/http" 12 | "os" 13 | "path/filepath" 14 | "slices" 15 | "strings" 16 | "unicode/utf8" 17 | 18 | "golang.org/x/crypto/ssh" 19 | ) 20 | 21 | // IsReadableFile checks if the file is readable and optionally checks for valid UTF-8 encoded content 22 | func IsReadableFile(filename string, isRemote bool, sshConfig *SSHConfig, checkUTF8 bool) (bool, error) { 23 | var file *os.File 24 | var err error 25 | 26 | if isRemote { 27 | file, err = sshOpenFile(filename, sshConfig) 28 | } else { 29 | file, err = os.Open(filename) 30 | } 31 | if err != nil { 32 | return false, err 33 | } 34 | defer file.Close() 35 | 36 | // Check if the file is empty 37 | fileInfo, err := file.Stat() 38 | if err != nil { 39 | return false, err 40 | } 41 | if fileInfo.Size() == 0 { 42 | return true, nil 43 | } 44 | 45 | buffer := make([]byte, 512) 46 | n, err := file.Read(buffer) 47 | if err != nil { 48 | return false, err 49 | } 50 | 51 | // Check if the file is gzip compressed 52 | if IsGzip(buffer[:n]) { 53 | _, err = file.Seek(0, io.SeekStart) // Reset file pointer 54 | if err != nil { 55 | return false, err 56 | } 57 | 58 | gzipReader, err := gzip.NewReader(file) 59 | if err != nil { 60 | return false, err 61 | } 62 | defer gzipReader.Close() 63 | 64 | n, err = gzipReader.Read(buffer) 65 | if err != nil && !errors.Is(err, io.EOF) { 66 | return false, err 67 | } 68 | 69 | if checkUTF8 { 70 | return utf8.Valid(buffer[:n]), nil 71 | } 72 | return true, nil 73 | } 74 | 75 | if checkUTF8 { 76 | return utf8.Valid(buffer[:n]), nil 77 | } 78 | return true, nil 79 | } 80 | 81 | // IsGzip checks if the given buffer starts with the gzip magic number 82 | func IsGzip(buffer []byte) bool { 83 | return len(buffer) >= 2 && buffer[0] == 0x1f && buffer[1] == 0x8b 84 | } 85 | 86 | func FilesByPattern(pattern string, isRemote bool, sshConfig *SSHConfig) ([]string, error) { 87 | if isRemote { 88 | return sshFilesByPattern(pattern, sshConfig) 89 | } 90 | 91 | // Check if the pattern is a directory 92 | info, err := os.Stat(pattern) 93 | if err == nil && info.IsDir() { 94 | // List all files in the directory 95 | var files []string 96 | err := filepath.Walk(pattern, func(path string, info os.FileInfo, err error) error { 97 | if err != nil { 98 | return err 99 | } 100 | if !info.IsDir() { 101 | files = append(files, path) 102 | } 103 | return nil 104 | }) 105 | if err != nil { 106 | return nil, err 107 | } 108 | return files, nil 109 | } 110 | 111 | // If pattern is not a directory, use Glob to match the pattern 112 | files, err := filepath.Glob(pattern) 113 | if err != nil { 114 | return nil, err 115 | } 116 | return files, nil 117 | } 118 | 119 | func detectMimeType(file *os.File) (string, error) { 120 | buffer := make([]byte, 512) 121 | _, err := file.Read(buffer) 122 | if err != nil { 123 | return "", err 124 | } 125 | // Reset the file pointer to the beginning of the file 126 | _, err = file.Seek(0, 0) 127 | if err != nil { 128 | return "", err 129 | } 130 | return http.DetectContentType(buffer), nil 131 | } 132 | 133 | // FileStats returns the number of lines and size of the file at the given path. 134 | func FileStats(filePath string, isRemote bool, sshConfig *SSHConfig) (int, int64, error) { 135 | var file *os.File 136 | var err error 137 | 138 | if isRemote { 139 | file, err = sshOpenFile(filePath, sshConfig) 140 | } else { 141 | file, err = os.Open(filePath) 142 | } 143 | if err != nil { 144 | return 0, 0, err 145 | } 146 | defer file.Close() 147 | 148 | mimeType, err := detectMimeType(file) 149 | if err != nil { 150 | return 0, 0, err 151 | } 152 | 153 | var reader *bufio.Reader 154 | if mimeType == "application/x-gzip" { 155 | gzReader, err := gzip.NewReader(file) 156 | if err != nil { 157 | return 0, 0, err 158 | } 159 | defer gzReader.Close() 160 | reader = bufio.NewReader(gzReader) 161 | } else { 162 | reader = bufio.NewReader(file) 163 | } 164 | 165 | var linesCount int 166 | scanner := bufio.NewScanner(reader) 167 | // Increase buffer size to 10MB 168 | buf := make([]byte, 10*1024*1024) // 10MB buffer 169 | scanner.Buffer(buf, len(buf)) 170 | 171 | for scanner.Scan() { 172 | linesCount++ 173 | } 174 | 175 | if err := scanner.Err(); err != nil { 176 | return 0, 0, err 177 | } 178 | 179 | fileInfo, err := file.Stat() 180 | if err != nil { 181 | return 0, 0, err 182 | } 183 | fileSize := fileInfo.Size() 184 | 185 | return linesCount, fileSize, nil 186 | } 187 | 188 | func GetFileInfos(pattern string, limit int, isRemote bool, sshConfig *SSHConfig) []FileInfo { 189 | filePaths, err := FilesByPattern(pattern, isRemote, sshConfig) 190 | if err != nil { 191 | slog.Error("getting file paths by pattern", pattern, err) 192 | return nil 193 | } 194 | if len(filePaths) == 0 { 195 | slog.Error("No files found", "pattern", pattern) 196 | return nil 197 | } 198 | fileInfos := make([]FileInfo, 0) 199 | if len(filePaths) > limit { 200 | slog.Warn("Limiting to files", "limit", limit) 201 | filePaths = filePaths[:limit] 202 | } 203 | 204 | for _, filePath := range filePaths { 205 | isText, err := IsReadableFile(filePath, isRemote, sshConfig, false) 206 | if err != nil { 207 | slog.Error("checking if file is readable", filePath, err) 208 | return nil 209 | } 210 | if !isText { 211 | slog.Warn("File is not a text file", "filePath", filePath) 212 | continue 213 | } 214 | linesCount, fileSize, err := FileStats(filePath, isRemote, sshConfig) 215 | if err != nil { 216 | if errors.Is(err, io.EOF) { 217 | slog.Warn("File is empty", "filePath", filePath) 218 | linesCount = 0 219 | fileSize = 0 220 | } else { 221 | slog.Error("getting file stats", filePath, err) 222 | continue 223 | } 224 | } 225 | t := TypeFile 226 | h := "" 227 | if isRemote { 228 | t = TypeSSH 229 | h = sshConfig.Host 230 | } 231 | if filePath == GlobalPipeTmpFilePath { 232 | t = TypeStdin 233 | } 234 | fileInfos = append(fileInfos, FileInfo{FilePath: filePath, LinesCount: linesCount, FileSize: fileSize, Type: t, Host: h}) 235 | } 236 | return fileInfos 237 | } 238 | 239 | // SSHConfig holds the SSH connection parameters 240 | type SSHConfig struct { 241 | Host string 242 | Port string 243 | User string 244 | Password string 245 | PrivateKeyPath string 246 | } 247 | 248 | type SSHPathConfig struct { 249 | Host string 250 | Port string 251 | User string 252 | Password string 253 | PrivateKeyPath string 254 | FilePath string 255 | } 256 | 257 | type DockerPathConfig struct { 258 | ContainerID string 259 | FilePath string 260 | } 261 | 262 | // s is an input of the form "container_id /path/to/file" 263 | func StringToDockerPathConfig(s string) (*DockerPathConfig, error) { 264 | // Split the input string into parts 265 | parts := strings.Fields(s) 266 | 267 | // There should be 2 parts: "container_id" and "/path/to/file" 268 | if len(parts) < 2 { 269 | return nil, fmt.Errorf("input string does not have the correct format") 270 | } 271 | return &DockerPathConfig{ 272 | ContainerID: parts[0], 273 | FilePath: parts[1], 274 | }, nil 275 | } 276 | 277 | // s is an input of the form "user@host[:port] [password=/path/to/password] [private_key=/path/to/key] /path/to/file" 278 | func StringToSSHPathConfig(s string) (*SSHPathConfig, error) { 279 | config := &SSHPathConfig{} 280 | 281 | // Split the input string into parts 282 | parts := strings.Fields(s) 283 | 284 | // There should be at least 2 parts: "user@host[:port]" and "/path/to/file" 285 | if len(parts) < 2 { 286 | return nil, fmt.Errorf("input string does not have the correct format") 287 | } 288 | 289 | // Extract user@host[:port] 290 | userHostPort := strings.Split(parts[0], "@") 291 | if len(userHostPort) != 2 { 292 | return nil, fmt.Errorf("user@host[:port] part does not have the correct format") 293 | } 294 | 295 | userHost := strings.Split(userHostPort[1], ":") 296 | config.User = userHostPort[0] 297 | config.Host = userHost[0] 298 | 299 | // Set the default port if not specified 300 | if len(userHost) == 2 { 301 | config.Port = userHost[1] 302 | } else { 303 | config.Port = "22" // Default SSH port 304 | } 305 | 306 | // Default private key path 307 | config.PrivateKeyPath = fmt.Sprintf("%s/.ssh/id_rsa", os.Getenv("HOME")) 308 | 309 | // Extract optional parts and file path 310 | for _, part := range parts[1:] { 311 | // nolint: gocritic 312 | if strings.HasPrefix(part, "password=") { 313 | config.Password = strings.TrimPrefix(part, "password=") 314 | } else if strings.HasPrefix(part, "private_key=") { 315 | config.PrivateKeyPath = strings.TrimPrefix(part, "private_key=") 316 | } else { 317 | config.FilePath = part 318 | } 319 | } 320 | 321 | if config.FilePath == "" { 322 | return nil, fmt.Errorf("file path is missing") 323 | } 324 | 325 | return config, nil 326 | } 327 | 328 | func sshConnect(config *SSHConfig) (*ssh.Client, error) { 329 | var auth []ssh.AuthMethod 330 | 331 | if config.Password != "" { 332 | auth = append(auth, ssh.Password(config.Password)) 333 | } 334 | if config.PrivateKeyPath != "" { 335 | key, err := os.ReadFile(config.PrivateKeyPath) 336 | if err != nil { 337 | return nil, err 338 | } 339 | signer, err := ssh.ParsePrivateKey(key) 340 | if err != nil { 341 | return nil, err 342 | } 343 | auth = append(auth, ssh.PublicKeys(signer)) 344 | } 345 | 346 | clientConfig := &ssh.ClientConfig{ 347 | User: config.User, 348 | Auth: auth, 349 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), // nolint:gosec 350 | } 351 | 352 | client, err := ssh.Dial("tcp", config.Host+":"+config.Port, clientConfig) 353 | if err != nil { 354 | return nil, err 355 | } 356 | 357 | return client, nil 358 | } 359 | 360 | func sshOpenFile(filename string, config *SSHConfig) (*os.File, error) { 361 | session, err := NewSession(config) 362 | if err != nil { 363 | return nil, err 364 | } 365 | defer session.Close() 366 | 367 | tmpFile, err := os.Create(GetTmpFileNameForSTDIN()) 368 | if err != nil { 369 | return nil, err 370 | } 371 | 372 | // Execute the cat command to read the file 373 | var stdout bytes.Buffer 374 | session.Stdout = &stdout 375 | if err := session.Run("cat " + filename); err != nil { 376 | if err.Error() != ErrorMsgSessionAlreadyStarted { 377 | return nil, err 378 | } 379 | } 380 | 381 | // Write the remote file content to the temporary file 382 | if _, err := tmpFile.Write(stdout.Bytes()); err != nil { 383 | return nil, err 384 | } 385 | 386 | // Seek to the beginning of the temporary file 387 | if _, err := tmpFile.Seek(0, io.SeekStart); err != nil { 388 | return nil, err 389 | } 390 | 391 | return tmpFile, nil 392 | } 393 | 394 | func sshFilesByPattern(pattern string, config *SSHConfig) ([]string, error) { 395 | session, err := NewSession(config) 396 | if err != nil { 397 | return nil, err 398 | } 399 | defer session.Close() 400 | 401 | var buf bytes.Buffer 402 | session.Stdout = &buf 403 | 404 | // Execute the ls command to list files matching the pattern 405 | if err := session.Run("ls " + pattern); err != nil { 406 | if err.Error() != ErrorMsgSessionAlreadyStarted { 407 | return nil, err 408 | } 409 | } 410 | 411 | filePaths := buf.String() 412 | return strings.Split(strings.TrimSpace(filePaths), "\n"), nil 413 | } 414 | 415 | func UniqueFileInfos(fileInfos []FileInfo) []FileInfo { 416 | eq := func(a, b FileInfo) bool { 417 | return a.FilePath == b.FilePath && a.Type == b.Type && a.Host == b.Host 418 | } 419 | return slices.CompactFunc(fileInfos, eq) 420 | } 421 | -------------------------------------------------------------------------------- /pkg/files_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestIsReadableFile(t *testing.T) { 12 | // Create a temporary directory for the test files 13 | dir := t.TempDir() 14 | 15 | // Create a temporary UTF-8 encoded file 16 | utf8File := filepath.Join(dir, "utf8.txt") 17 | if err := os.WriteFile(utf8File, []byte("hello, world!"), 0600); err != nil { 18 | t.Fatalf("failed to create UTF-8 file: %v", err) 19 | } 20 | 21 | // Create a temporary gzip-compressed UTF-8 file 22 | gzipFile := filepath.Join(dir, "utf8.txt.gz") 23 | gzipContent := []byte("hello, gzipped world!") 24 | var buf bytes.Buffer 25 | gzipWriter := gzip.NewWriter(&buf) 26 | if _, err := gzipWriter.Write(gzipContent); err != nil { 27 | t.Fatalf("failed to write gzip content: %v", err) 28 | } 29 | gzipWriter.Close() 30 | if err := os.WriteFile(gzipFile, buf.Bytes(), 0600); err != nil { 31 | t.Fatalf("failed to create gzip file: %v", err) 32 | } 33 | 34 | // Test cases 35 | tests := []struct { 36 | filename string 37 | expectErr bool 38 | expectBool bool 39 | }{ 40 | {utf8File, false, true}, 41 | {gzipFile, false, true}, 42 | {"nonexistent.txt", true, false}, 43 | } 44 | 45 | for _, test := range tests { 46 | t.Run(test.filename, func(t *testing.T) { 47 | result, err := IsReadableFile(test.filename, false, nil, false) 48 | if (err != nil) != test.expectErr { 49 | t.Errorf("IsReadableFile(%q) error = %v, wantErr %v", test.filename, err, test.expectErr) 50 | return 51 | } 52 | if result != test.expectBool { 53 | t.Errorf("IsReadableFile(%q) = %v, want %v", test.filename, result, test.expectBool) 54 | } 55 | }) 56 | } 57 | } 58 | 59 | func TestIsGzip(t *testing.T) { 60 | tests := []struct { 61 | name string 62 | buffer []byte 63 | want bool 64 | }{ 65 | {"gzip header", []byte{0x1f, 0x8b}, true}, 66 | {"not gzip header", []byte{0x00, 0x00}, false}, 67 | {"empty buffer", []byte{}, false}, 68 | {"partial gzip header", []byte{0x1f}, false}, 69 | } 70 | 71 | for _, test := range tests { 72 | t.Run(test.name, func(t *testing.T) { 73 | if got := IsGzip(test.buffer); got != test.want { 74 | t.Errorf("IsGzip(%v) = %v, want %v", test.buffer, got, test.want) 75 | } 76 | }) 77 | } 78 | } 79 | 80 | func TestFilesByPattern(t *testing.T) { 81 | // Create a temporary directory for the test files 82 | dir := t.TempDir() 83 | 84 | // Create some test files 85 | files := []string{ 86 | filepath.Join(dir, "file1.txt"), 87 | filepath.Join(dir, "file2.txt"), 88 | filepath.Join(dir, "file3.log"), 89 | } 90 | for _, file := range files { 91 | if err := os.WriteFile(file, []byte("test"), 0600); err != nil { 92 | t.Fatalf("failed to create file %q: %v", file, err) 93 | } 94 | } 95 | 96 | tests := []struct { 97 | pattern string 98 | expectErr bool 99 | expectFiles []string 100 | }{ 101 | {dir, false, files}, 102 | {filepath.Join(dir, "*.txt"), false, files[:2]}, 103 | {filepath.Join(dir, "*.log"), false, files[2:3]}, 104 | {filepath.Join(dir, "*.none"), false, []string{}}, 105 | {"nonexistent", false, nil}, 106 | } 107 | 108 | for _, test := range tests { 109 | t.Run(test.pattern, func(t *testing.T) { 110 | result, err := FilesByPattern(test.pattern, false, nil) 111 | if (err != nil) != test.expectErr { 112 | t.Errorf("FilesByPattern(%q) error = %v, wantErr %v", test.pattern, err, test.expectErr) 113 | return 114 | } 115 | if len(result) != len(test.expectFiles) { 116 | t.Errorf("FilesByPattern(%q) = %v, want %v", test.pattern, result, test.expectFiles) 117 | } 118 | }) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /pkg/globals.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "golang.org/x/crypto/ssh" 10 | ) 11 | 12 | var GlobalFilePaths []FileInfo 13 | var GlobalPipeTmpFilePath string 14 | var GlobalPathSSHConfig []SSHPathConfig 15 | var GlobalSSHClients = make(map[string]*ssh.Client) 16 | 17 | func WatchFilePaths(seconds int64, filePaths SliceFlags, sshPaths SliceFlags, dockerPaths SliceFlags, limit int) { 18 | interval := time.Duration(seconds) * time.Second 19 | ticker := time.NewTicker(interval) 20 | defer ticker.Stop() 21 | 22 | for range ticker.C { 23 | slog.Info("Checking for filepaths", "interval", interval) 24 | UpdateGlobalFilePaths(filePaths, sshPaths, dockerPaths, limit) 25 | } 26 | } 27 | 28 | func HandleStdinPipe() { 29 | tmpFile, err := os.Create(GetTmpFileNameForSTDIN()) 30 | if err != nil { 31 | slog.Error("creating temp file", tmpFile.Name(), err) 32 | return 33 | } 34 | GlobalPipeTmpFilePath = tmpFile.Name() 35 | defer tmpFile.Close() 36 | go func(tmpFile *os.File) { 37 | err := PipeLinesToTmp(tmpFile) 38 | if err != nil { 39 | slog.Error("piping lines to temp file", tmpFile.Name(), err) 40 | return 41 | } 42 | }(tmpFile) 43 | } 44 | 45 | func UpdateGlobalFilePaths(filePaths SliceFlags, sshPaths SliceFlags, dockerPaths SliceFlags, limit int) { 46 | fileInfos := []FileInfo{} 47 | for _, pattern := range filePaths { 48 | fileInfo := GetFileInfos(pattern, limit, false, nil) 49 | fileInfos = append(fileInfo, fileInfos...) 50 | } 51 | for _, pattern := range sshPaths { 52 | sshFilePathConfig, err := StringToSSHPathConfig(pattern) 53 | if err != nil { 54 | slog.Error("parsing SSH path", pattern, err) 55 | break 56 | } 57 | sshConfig := SSHConfig{ 58 | Host: sshFilePathConfig.Host, 59 | Port: sshFilePathConfig.Port, 60 | User: sshFilePathConfig.User, 61 | Password: sshFilePathConfig.Password, 62 | PrivateKeyPath: sshFilePathConfig.PrivateKeyPath, 63 | } 64 | GlobalPathSSHConfig = append(GlobalPathSSHConfig, *sshFilePathConfig) 65 | fileInfo := GetFileInfos(sshFilePathConfig.FilePath, limit, true, &sshConfig) 66 | fileInfos = append(fileInfo, fileInfos...) 67 | } 68 | 69 | for _, pattern := range dockerPaths { 70 | containers, err := ListDockerContainers() 71 | if err != nil { 72 | slog.Error("listing Docker containers", pattern, err) 73 | break 74 | } 75 | if pattern == "" || len(strings.Fields(pattern)) == 1 { 76 | for _, container := range containers { 77 | if pattern != "" && !strings.Contains(container.Names[0], pattern) { 78 | continue 79 | } 80 | tmpFile := ContainerStdoutToTmp(container.ID) 81 | if tmpFile == nil { 82 | slog.Error("creating temp file for container logs", "containerID", container.ID) 83 | continue 84 | } 85 | fileInfo := GetFileInfos(tmpFile.Name(), limit, false, nil) 86 | if len(fileInfo) > 0 { 87 | fileInfo[0].Host = container.ID[:12] 88 | fileInfo[0].Type = TypeDocker 89 | fileInfo[0].Name = container.Names[0][1:] 90 | fileInfos = append(fileInfo, fileInfos...) 91 | } 92 | } 93 | } 94 | if len(strings.Fields(pattern)) == 2 { 95 | dockerFilePathConfig, err := StringToDockerPathConfig(pattern) 96 | if err != nil { 97 | slog.Error("parsing Docker path", pattern, err) 98 | break 99 | } 100 | fileInfo := GetContainerFileInfos(dockerFilePathConfig.FilePath, limit, dockerFilePathConfig.ContainerID) 101 | fileInfos = append(fileInfo, fileInfos...) 102 | } 103 | } 104 | 105 | GlobalFilePaths = UniqueFileInfos(fileInfos) 106 | } 107 | -------------------------------------------------------------------------------- /pkg/log.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/lmittmann/tint" 11 | "github.com/mattn/go-isatty" 12 | ) 13 | 14 | func SetupLoggingStdout(logLevel slog.Leveler) { 15 | w := os.Stderr 16 | handler := tint.NewHandler(w, &tint.Options{ 17 | NoColor: !isatty.IsTerminal(w.Fd()), 18 | AddSource: true, 19 | Level: logLevel, 20 | ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr { 21 | if a.Key == slog.TimeKey { 22 | t := a.Value.Time() 23 | a.Value = slog.StringValue(t.Format(time.DateTime)) 24 | } 25 | if a.Key == slog.SourceKey { 26 | source := a.Value.Any().(*slog.Source) 27 | a.Value = slog.StringValue(filepath.Base(source.File) + ":" + fmt.Sprint(source.Line)) 28 | } 29 | return a 30 | }, 31 | }) 32 | 33 | slog.SetDefault(slog.New(handler)) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/responses.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | func SetHeadersResponsePNG(header http.Header) { 10 | header.Set("Cache-Control", "max-age=10") 11 | header.Set("Expires", "10") 12 | header.Set("Content-Type", "image/png") 13 | // security headers 14 | header.Set("X-Content-Type-Options", "nosniff") 15 | header.Set("X-Frame-Options", "DENY") 16 | header.Set("X-XSS-Protection", "1; mode=block") 17 | // content policy 18 | header.Set("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; script-src 'self';") 19 | } 20 | 21 | func SetHeadersResponseSvg(header http.Header) { 22 | header.Set("Cache-Control", "max-age=10") 23 | header.Set("Expires", "10") 24 | header.Set("Content-Type", "image/svg+xml") 25 | // security headers 26 | header.Set("X-Content-Type-Options", "nosniff") 27 | header.Set("X-Frame-Options", "DENY") 28 | header.Set("X-XSS-Protection", "1; mode=block") 29 | } 30 | func SetHeadersResponseJSON(header http.Header) { 31 | header.Set("Cache-Control", "max-age=10") 32 | header.Set("Expires", "10") 33 | header.Set("Content-Type", "application/json") 34 | // security headers 35 | header.Set("X-Content-Type-Options", "nosniff") 36 | header.Set("X-Frame-Options", "DENY") 37 | header.Set("X-XSS-Protection", "1; mode=block") 38 | // content policy 39 | header.Set("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; script-src 'self';") 40 | } 41 | 42 | func SetHeadersResponseHTML(header http.Header, cacheMS string) { 43 | header.Set("Cache-Control", "max-age="+cacheMS) 44 | header.Set("Expires", cacheMS) 45 | header.Set("Content-Type", "text/html; charset=utf-8") 46 | // security headers 47 | header.Set("X-Content-Type-Options", "nosniff") 48 | header.Set("X-Frame-Options", "DENY") 49 | header.Set("X-XSS-Protection", "1; mode=block") 50 | } 51 | 52 | func SetHeadersResponsePlainText(header http.Header, cacheMS string) { 53 | header.Set("Cache-Control", "max-age="+cacheMS) 54 | header.Set("Expires", cacheMS) 55 | header.Set("Content-Type", "text/plain") 56 | // security headers 57 | header.Set("X-Content-Type-Options", "nosniff") 58 | header.Set("X-Frame-Options", "DENY") 59 | header.Set("X-XSS-Protection", "1; mode=block") 60 | // content policy 61 | header.Set("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'; connect-src 'self'; script-src 'self';") 62 | } 63 | 64 | func ResponseHTML(c echo.Context, b []byte, cacheMS string) error { 65 | SetHeadersResponseHTML(c.Response().Header(), cacheMS) 66 | return c.Blob(http.StatusOK, "text/html", b) 67 | } 68 | func ResponsePlain(c echo.Context, b []byte, cacheMS string) error { 69 | SetHeadersResponsePlainText(c.Response().Header(), cacheMS) 70 | return c.Blob(http.StatusOK, "text/plain", b) 71 | } 72 | -------------------------------------------------------------------------------- /pkg/slices.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | type SliceFlags []string 4 | 5 | // provide a String() method on the type so we can use it with flag.Var 6 | func (i *SliceFlags) String() string { 7 | return "" 8 | } 9 | 10 | // provide a Set() method on the type so we can use it with flag.Var 11 | func (i *SliceFlags) Set(value string) error { 12 | *i = append(*i, value) 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /pkg/ssh.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "sync" 5 | 6 | "golang.org/x/crypto/ssh" 7 | ) 8 | 9 | var ( 10 | clientMutex = &sync.Mutex{} 11 | ) 12 | 13 | func NewSession(config *SSHConfig) (*ssh.Session, error) { 14 | client, err := NewOrReusableClient(config) 15 | if err != nil { 16 | return nil, err 17 | } 18 | session, err := client.NewSession() 19 | if err != nil { 20 | return nil, err 21 | } 22 | return session, nil 23 | } 24 | 25 | func NewOrReusableClient(config *SSHConfig) (*ssh.Client, error) { 26 | key := config.Host + ":" + config.Port 27 | 28 | clientMutex.Lock() 29 | client := GlobalSSHClients[key] 30 | clientMutex.Unlock() 31 | 32 | if client == nil { 33 | c, err := sshConnect(config) 34 | if err != nil { 35 | return nil, err 36 | } 37 | clientMutex.Lock() 38 | GlobalSSHClients[key] = c 39 | clientMutex.Unlock() 40 | client = c 41 | } 42 | 43 | // check if client is still connected 44 | _, _, err := client.SendRequest("", true, nil) 45 | if err != nil { 46 | clientMutex.Lock() 47 | delete(GlobalSSHClients, key) 48 | clientMutex.Unlock() 49 | return NewOrReusableClient(config) 50 | } 51 | 52 | return client, nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/strings.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "strconv" 7 | "strings" 8 | "sync" 9 | "unicode" 10 | 11 | "github.com/acarl005/stripansi" 12 | "github.com/gravwell/gravwell/v3/timegrinder" 13 | "github.com/mileusna/useragent" 14 | ) 15 | 16 | func F64NumberToK(num *float64) string { 17 | if num == nil { 18 | return "0" 19 | } 20 | 21 | if *num < 1000 { 22 | return strconv.FormatFloat(*num, 'f', -1, 64) 23 | } 24 | 25 | if *num < 1000000 { 26 | return strconv.FormatFloat(*num/1000, 'f', 1, 64) + "k" 27 | } 28 | 29 | return strconv.FormatFloat(*num/1000000, 'f', 1, 64) + "m" 30 | } 31 | 32 | func StringInSlice(s string, ss []string) bool { 33 | for _, v := range ss { 34 | if v == s { 35 | return true 36 | } 37 | } 38 | return false 39 | } 40 | func FilePathInGlobalFilePaths(filePath string) bool { 41 | for _, fileInfo := range GlobalFilePaths { 42 | if fileInfo.FilePath == filePath { 43 | return true 44 | } 45 | } 46 | return false 47 | } 48 | 49 | // CleanString removes non-printable characters from a string 50 | func CleanString(input string) string { 51 | cleaned := make([]rune, 0, len(input)) 52 | for _, r := range input { 53 | if unicode.IsPrint(r) { 54 | cleaned = append(cleaned, r) 55 | } 56 | } 57 | // remove first % 58 | if len(cleaned) > 0 && cleaned[0] == '%' { 59 | cleaned = cleaned[1:] 60 | } 61 | return string(cleaned) 62 | } 63 | 64 | // JudgeLogLevel returns the log level based on the content of the log line if the format is consistent 65 | func JudgeLogLevel(line string, keywordPosition int) string { 66 | line = strings.ToLower(line) // Convert the line to lowercase for easier comparison 67 | 68 | // Keywords for different log levels 69 | successKeywords := []string{"success", "SUCCESS", "succ", "SUCC", "Success"} 70 | infoKeywords := []string{"info", "inf", "INFO", "INF", "Info", "Inf"} 71 | errorKeywords := []string{"error", "err", "fail", "ERROR", "ERR", "FAIL", "Error", "Err", "Fail"} 72 | warnKeywords := []string{"warn", "warning", "alert", "wrn", "WARN", "WARNING", "ALERT", "Wrn", "Wrning", "Alert"} 73 | dangerKeywords := []string{"danger", "fatal", "severe", "critical", "DANGER", "FATAL", "SEVERE", "CRITICAL", "Danger", "Fatal", "Severe", "Critical"} 74 | debugKeywords := []string{"debug", "dbg", "DEBUG", "DBG", "Debug"} 75 | 76 | // Helper function to check if a keyword is at a specific position 77 | isKeywordAtPosition := func(line, keyword string, position int) bool { 78 | return strings.Index(line, keyword) == position 79 | } 80 | 81 | // Check for keywords at the specified position 82 | for _, keyword := range successKeywords { 83 | if isKeywordAtPosition(line, keyword, keywordPosition) { 84 | return "success" 85 | } 86 | } 87 | for _, keyword := range infoKeywords { 88 | if isKeywordAtPosition(line, keyword, keywordPosition) { 89 | return "info" 90 | } 91 | } 92 | 93 | for _, keyword := range errorKeywords { 94 | if isKeywordAtPosition(line, keyword, keywordPosition) { 95 | return "error" 96 | } 97 | } 98 | 99 | for _, keyword := range warnKeywords { 100 | if isKeywordAtPosition(line, keyword, keywordPosition) { 101 | return "warn" 102 | } 103 | } 104 | 105 | for _, keyword := range dangerKeywords { 106 | if isKeywordAtPosition(line, keyword, keywordPosition) { 107 | return "danger" 108 | } 109 | } 110 | 111 | for _, keyword := range debugKeywords { 112 | if isKeywordAtPosition(line, keyword, keywordPosition) { 113 | return "debug" 114 | } 115 | } 116 | 117 | // Default log level if no keywords match 118 | return "" 119 | } 120 | 121 | // ConsistentFormat checks if all log lines have log levels at the same position 122 | func ConsistentFormat(logLines []string) (bool, int) { 123 | if len(logLines) == 0 { 124 | return false, -1 125 | } 126 | 127 | positions := []int{} 128 | 129 | for _, line := range logLines { 130 | line = strings.ToLower(line) 131 | words := strings.FieldsFunc(line, func(c rune) bool { 132 | return !unicode.IsLetter(c) 133 | }) 134 | 135 | if len(words) == 0 { 136 | continue 137 | } 138 | 139 | firstWord := words[0] 140 | 141 | position := strings.Index(line, firstWord) 142 | positions = append(positions, position) 143 | } 144 | 145 | consistentPosition := positions[0] 146 | for _, pos := range positions { 147 | if pos != consistentPosition { 148 | return false, -1 149 | } 150 | } 151 | 152 | return true, consistentPosition 153 | } 154 | 155 | func AppendGeneralInfo(lines *[]LineResult) { 156 | appendAgent(lines) 157 | appendDates(lines) 158 | appendLogLevel(lines) 159 | } 160 | 161 | func appendLogLevel(lines *[]LineResult) { 162 | logLines := []string{} 163 | for _, line := range *lines { 164 | line.Content = stripansi.Strip(line.Content) 165 | logLines = append(logLines, line.Content) 166 | } 167 | 168 | isConsistent, keywordPosition := ConsistentFormat(logLines) 169 | if isConsistent { 170 | for i, line := range *lines { 171 | (*lines)[i].Level = JudgeLogLevel(line.Content, keywordPosition) 172 | } 173 | } 174 | } 175 | 176 | func appendAgent(lines *[]LineResult) { 177 | defer func() { 178 | if r := recover(); r != nil { 179 | fmt.Println("Recovered from panic:", r) 180 | } 181 | }() 182 | for i, line := range *lines { 183 | ua := useragent.Parse(line.Content) 184 | device := "server" 185 | 186 | switch { 187 | case ua.Desktop: 188 | device = "desktop" 189 | case ua.Mobile: 190 | device = "mobile" 191 | case ua.Tablet: 192 | device = "tablet" 193 | } 194 | 195 | (*lines)[i].Agent.Device = device 196 | } 197 | } 198 | 199 | func appendDates(lines *[]LineResult) { 200 | defer func() { 201 | if r := recover(); r != nil { 202 | fmt.Println("Recovered from panic:", r) 203 | } 204 | }() 205 | for i, line := range *lines { 206 | date := searchDate(line.Content) 207 | (*lines)[i].Date = date 208 | } 209 | } 210 | 211 | var ( 212 | tg *timegrinder.TimeGrinder 213 | once sync.Once 214 | ) 215 | 216 | func initTimeGrinder() error { 217 | cfg := timegrinder.Config{} 218 | var err error 219 | tg, err = timegrinder.NewTimeGrinder(cfg) 220 | if err != nil { 221 | return err 222 | } 223 | return nil 224 | } 225 | 226 | func searchDate(input string) string { 227 | var initErr error 228 | once.Do(func() { 229 | initErr = initTimeGrinder() 230 | }) 231 | if initErr != nil { 232 | slog.Error("Error initializing", "timegrinder", initErr) 233 | return "" 234 | } 235 | ts, ok, err := tg.Extract([]byte(input)) 236 | if err != nil { 237 | return "" 238 | } 239 | if !ok { 240 | return "" 241 | } 242 | return ts.String() 243 | } 244 | -------------------------------------------------------------------------------- /pkg/strings_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestStringInSlice(t *testing.T) { 8 | tests := []struct { 9 | s string 10 | ss []string 11 | want bool 12 | }{ 13 | {"hello", []string{"hello", "world"}, true}, 14 | {"go", []string{"golang", "python", "java"}, false}, 15 | {"java", []string{"golang", "python", "java"}, true}, 16 | } 17 | 18 | for _, tt := range tests { 19 | t.Run(tt.s, func(t *testing.T) { 20 | got := StringInSlice(tt.s, tt.ss) 21 | if got != tt.want { 22 | t.Errorf("StringInSlice(%s, %v) = %v; want %v", tt.s, tt.ss, got, tt.want) 23 | } 24 | }) 25 | } 26 | } 27 | 28 | func TestJudgeLogLevel(t *testing.T) { 29 | tests := []struct { 30 | line string 31 | keywordPosition int 32 | want string 33 | }{ 34 | {"INFO: Everything is working fine.", 0, "info"}, 35 | {"error: Failed to connect to the database.", 0, "error"}, 36 | {"warn: Deprecated API usage.", 0, "warn"}, 37 | {"fatal: Unexpected null pointer exception.", 0, "danger"}, 38 | {"debug: Variable x has value 10.", 0, "debug"}, 39 | {"INFO: Just another log entry.", 0, "info"}, 40 | {"This is just a normal log entry.", 0, ""}, 41 | } 42 | 43 | for _, tt := range tests { 44 | t.Run(tt.line, func(t *testing.T) { 45 | got := JudgeLogLevel(tt.line, tt.keywordPosition) 46 | if got != tt.want { 47 | t.Errorf("JudgeLogLevel(%s, %d) = %s; want %s", tt.line, tt.keywordPosition, got, tt.want) 48 | } 49 | }) 50 | } 51 | } 52 | 53 | func TestConsistentFormat(t *testing.T) { 54 | tests := []struct { 55 | logLines []string 56 | wantConsistent bool 57 | wantPosition int 58 | }{ 59 | { 60 | []string{ 61 | "INFO: Everything is working fine.", 62 | "ERROR: Failed to connect to the database.", 63 | "WARNING: Deprecated API usage.", 64 | "FATAL: Unexpected null pointer exception.", 65 | "DEBUG: Variable x has value 10.", 66 | }, 67 | true, 68 | 0, 69 | }, 70 | { 71 | []string{ 72 | "INFO - Everything is working fine.", 73 | "ERROR - Failed to connect to the database.", 74 | "WARNING - Deprecated API usage.", 75 | "FATAL - Unexpected null pointer exception.", 76 | "DEBUG - Variable x has value 10.", 77 | }, 78 | true, 79 | 0, 80 | }, 81 | } 82 | 83 | for _, tt := range tests { 84 | t.Run("", func(t *testing.T) { 85 | gotConsistent, gotPosition := ConsistentFormat(tt.logLines) 86 | if gotConsistent != tt.wantConsistent || gotPosition != tt.wantPosition { 87 | t.Errorf("ConsistentFormat(%v) = %v, %d; want %v, %d", tt.logLines, gotConsistent, gotPosition, tt.wantConsistent, tt.wantPosition) 88 | } 89 | }) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /pkg/system.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bufio" 5 | "log/slog" 6 | "os" 7 | "os/exec" 8 | "os/signal" 9 | "runtime" 10 | 11 | "github.com/acarl005/stripansi" 12 | "github.com/kevincobain2000/go-human-uuid/lib" 13 | ) 14 | 15 | func GetHomedir() string { 16 | home, err := os.UserHomeDir() 17 | if err != nil { 18 | return "" 19 | } 20 | return home 21 | } 22 | 23 | // IsInputFromPipe checks if there is input from a pipe 24 | func IsInputFromPipe() bool { 25 | fileInfo, err := os.Stdin.Stat() 26 | if err != nil { 27 | return false 28 | } 29 | 30 | // Check if the mode is a character device (i.e., a pipe) 31 | return (fileInfo.Mode() & os.ModeCharDevice) == 0 32 | } 33 | 34 | func PipeLinesToTmp(tmpFile *os.File) error { 35 | scanner := bufio.NewScanner(os.Stdin) 36 | 37 | slog.Info("Temporary file created for stdin", "path", GlobalPipeTmpFilePath) 38 | 39 | linesCount, fileSize, err := FileStats(GlobalPipeTmpFilePath, false, nil) 40 | if err != nil { 41 | slog.Error("creating FileInfo for temp file", GlobalPipeTmpFilePath, err) 42 | return err 43 | } 44 | tempFileInfo := FileInfo{FilePath: GlobalPipeTmpFilePath, LinesCount: linesCount, FileSize: fileSize, Type: TypeStdin} 45 | 46 | GlobalFilePaths = append([]FileInfo{tempFileInfo}, GlobalFilePaths...) 47 | slog.Info("Temporary file added to global file paths", "filePaths", GlobalFilePaths) 48 | 49 | lineCount := 0 50 | for scanner.Scan() { 51 | line := scanner.Text() 52 | line = stripansi.Strip(line) 53 | if lineCount >= 10000 { 54 | if err := tmpFile.Truncate(0); err != nil { 55 | slog.Error("truncating file", GlobalPipeTmpFilePath, err) 56 | } 57 | if _, err := tmpFile.Seek(0, 0); err != nil { 58 | slog.Error("seeking file", GlobalPipeTmpFilePath, err) 59 | } 60 | lineCount = 0 61 | } 62 | if _, err := tmpFile.WriteString(line + "\n"); err != nil { 63 | slog.Error("writing to file", GlobalPipeTmpFilePath, err) 64 | } 65 | lineCount++ 66 | } 67 | 68 | if err := scanner.Err(); err != nil { 69 | slog.Error("reading from pipe", GlobalPipeTmpFilePath, err) 70 | return err 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func GetTmpFileNameForSTDIN() string { 77 | gen, _ := lib.NewGenerator([]lib.Option{ 78 | func(opt *lib.Options) error { 79 | opt.Length = 2 80 | return nil 81 | }, 82 | }...) 83 | return TmpStdinPath + gen.Generate() 84 | } 85 | 86 | func GetTmpFileNameForContainer() string { 87 | gen, _ := lib.NewGenerator([]lib.Option{ 88 | func(opt *lib.Options) error { 89 | opt.Length = 6 90 | return nil 91 | }, 92 | }...) 93 | return TmpContainerPath + gen.Generate() 94 | } 95 | 96 | func OpenBrowser(url string) { 97 | var err error 98 | 99 | switch runtime.GOOS { 100 | case "linux": 101 | err = exec.Command("xdg-open", url).Start() 102 | case "windows": 103 | err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() 104 | case "darwin": 105 | err = exec.Command("open", url).Start() 106 | default: 107 | err = exec.Command("xdg-open", url).Start() 108 | } 109 | 110 | if err != nil { 111 | slog.Warn("Failed to open browser", "url", url) 112 | } 113 | } 114 | 115 | func HandleCltrC(f func()) { 116 | c := make(chan os.Signal, 1) 117 | signal.Notify(c, os.Interrupt) 118 | go func() { 119 | s := <-c 120 | slog.Warn("Got signal", "signal", s) 121 | f() 122 | close(c) 123 | os.Exit(1) 124 | }() 125 | } 126 | 127 | func Cleanup() { 128 | if GlobalPipeTmpFilePath == "" { 129 | return 130 | } 131 | err := os.Remove(GlobalPipeTmpFilePath) 132 | if err != nil { 133 | slog.Error("removing temp file", GlobalPipeTmpFilePath, err) 134 | return 135 | } 136 | slog.Info("temp file removed", "path", GlobalPipeTmpFilePath) 137 | } 138 | -------------------------------------------------------------------------------- /pkg/system_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "sync" 7 | "testing" 8 | ) 9 | 10 | // TestGetHomedir tests the GetHomedir function 11 | func TestGetHomedir(t *testing.T) { 12 | home := GetHomedir() 13 | if home == "" { 14 | t.Error("Expected a non-empty home directory") 15 | } 16 | } 17 | func TestIsInputFromPipe(t *testing.T) { 18 | // Create a pipe to simulate input from stdin 19 | reader, writer, err := os.Pipe() 20 | if err != nil { 21 | t.Fatalf("Failed to create pipe: %v", err) 22 | } 23 | defer reader.Close() 24 | defer writer.Close() 25 | 26 | // Redirect stdin to the reader end of the pipe 27 | oldStdin := os.Stdin 28 | defer func() { os.Stdin = oldStdin }() 29 | os.Stdin = reader 30 | 31 | var wg sync.WaitGroup 32 | wg.Add(1) 33 | 34 | go func() { 35 | defer wg.Done() 36 | _, err := writer.WriteString("test input\n") 37 | if err != nil { 38 | t.Errorf("Failed to write to pipe: %v", err) 39 | } 40 | writer.Close() 41 | }() 42 | 43 | // Wait for the goroutine to finish writing to the pipe 44 | wg.Wait() 45 | 46 | if !IsInputFromPipe() { 47 | t.Error("Expected IsInputFromPipe to return true") 48 | } 49 | } 50 | 51 | func TestUniqueFileInfos(t *testing.T) { 52 | tests := []struct { 53 | name string 54 | input []FileInfo 55 | expected []FileInfo 56 | }{ 57 | { 58 | name: "no duplicates", 59 | input: []FileInfo{ 60 | {FilePath: "path1", Type: "type1", Host: "host1"}, 61 | {FilePath: "path2", Type: "type2", Host: "host2"}, 62 | }, 63 | expected: []FileInfo{ 64 | {FilePath: "path1", Type: "type1", Host: "host1"}, 65 | {FilePath: "path2", Type: "type2", Host: "host2"}, 66 | }, 67 | }, 68 | { 69 | name: "with duplicates", 70 | input: []FileInfo{ 71 | {FilePath: "path1", Type: "type1", Host: "host1"}, 72 | {FilePath: "path1", Type: "type1", Host: "host1"}, 73 | {FilePath: "path2", Type: "type2", Host: "host2"}, 74 | {FilePath: "path2", Type: "type2", Host: "host2"}, 75 | }, 76 | expected: []FileInfo{ 77 | {FilePath: "path1", Type: "type1", Host: "host1"}, 78 | {FilePath: "path2", Type: "type2", Host: "host2"}, 79 | }, 80 | }, 81 | { 82 | name: "all duplicates", 83 | input: []FileInfo{ 84 | {FilePath: "path1", Type: "type1", Host: "host1"}, 85 | {FilePath: "path1", Type: "type1", Host: "host1"}, 86 | {FilePath: "path1", Type: "type1", Host: "host1"}, 87 | }, 88 | expected: []FileInfo{ 89 | {FilePath: "path1", Type: "type1", Host: "host1"}, 90 | }, 91 | }, 92 | { 93 | name: "empty input", 94 | input: []FileInfo{}, 95 | expected: []FileInfo{}, 96 | }, 97 | } 98 | 99 | for _, tt := range tests { 100 | t.Run(tt.name, func(t *testing.T) { 101 | actual := UniqueFileInfos(tt.input) 102 | if !reflect.DeepEqual(actual, tt.expected) { 103 | t.Errorf("UniqueFileInfos(%v) = %v; want %v", tt.input, actual, tt.expected) 104 | } 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /pkg/types.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | const ( 4 | TypeFile = "file" 5 | TypeStdin = "stdin" 6 | TypeSSH = "ssh" 7 | TypeDocker = "docker" 8 | TmpStdinPath = "/tmp/GOL-STDIN-" 9 | TmpContainerPath = "/tmp/GOL-CONTAINER-" 10 | 11 | ErrorMsgSessionAlreadyStarted = "ssh: session already started" 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/validator.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | 7 | "github.com/go-playground/validator" 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | var validate = validator.New() 12 | 13 | type ValidationErrs map[string][]string 14 | 15 | func ValidateRequest[T any](request T) (ValidationErrs, error) { 16 | errs := validate.Struct(request) 17 | validationErrs := ValidationErrs{} 18 | if errs != nil { 19 | // nolint:errorlint 20 | for _, err := range errs.(validator.ValidationErrors) { 21 | field, _ := reflect.TypeOf(request).Elem().FieldByName(err.Field()) 22 | queryTag := getStructTag(field, "query") 23 | message := err.Tag() + ":" + getStructTag(field, "message") 24 | validationErrs[queryTag] = append(validationErrs[queryTag], message) 25 | } 26 | return validationErrs, errs 27 | } 28 | return nil, nil 29 | } 30 | 31 | // getStructTag returns the value of the tag with the given name 32 | func getStructTag(f reflect.StructField, tagName string) string { 33 | return string(f.Tag.Get(tagName)) 34 | } 35 | 36 | func BindRequest[T any](c echo.Context, request T) error { 37 | err := c.Bind(request) 38 | if err != nil { 39 | return echo.NewHTTPError(http.StatusBadRequest, err.Error()) 40 | } 41 | 42 | return nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/watcher.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "compress/gzip" 7 | "fmt" 8 | "os" 9 | "regexp" 10 | "strings" 11 | "sync" 12 | 13 | "github.com/acarl005/stripansi" 14 | "golang.org/x/crypto/ssh" 15 | ) 16 | 17 | type Watcher struct { 18 | filePath string 19 | matchPattern string 20 | ignorePattern string 21 | mutex sync.Mutex 22 | sshConfig *ssh.ClientConfig 23 | sshHost string 24 | sshPort string 25 | isRemote bool 26 | } 27 | 28 | func NewWatcher( 29 | filePath string, 30 | matchPattern string, 31 | ignorePattern string, 32 | isRemote bool, 33 | sshHost string, 34 | sshPort string, 35 | sshUser string, 36 | sshPassword string, 37 | sshPrivateKeyPath string, 38 | ) (*Watcher, error) { 39 | var authMethod ssh.AuthMethod 40 | if sshPrivateKeyPath != "" { 41 | key, err := os.ReadFile(sshPrivateKeyPath) 42 | if err != nil { 43 | return nil, err 44 | } 45 | signer, err := ssh.ParsePrivateKey(key) 46 | if err != nil { 47 | return nil, err 48 | } 49 | authMethod = ssh.PublicKeys(signer) 50 | } else { 51 | authMethod = ssh.Password(sshPassword) 52 | } 53 | 54 | watcher := &Watcher{ 55 | filePath: filePath, 56 | matchPattern: matchPattern, 57 | ignorePattern: ignorePattern, 58 | isRemote: isRemote, 59 | sshHost: sshHost, 60 | sshPort: sshPort, 61 | sshConfig: &ssh.ClientConfig{ 62 | User: sshUser, 63 | Auth: []ssh.AuthMethod{ 64 | authMethod, 65 | }, 66 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), // nolint:gosec 67 | }, 68 | } 69 | 70 | return watcher, nil 71 | } 72 | 73 | type LineResult struct { 74 | LineNumber int `json:"line_number"` 75 | Content string `json:"content"` 76 | Level string `json:"level"` 77 | Date string `json:"date"` 78 | Agent struct { 79 | Device string `json:"device"` 80 | } `json:"agent"` 81 | } 82 | 83 | type ScanResult struct { 84 | FilePath string `json:"file_path"` 85 | Host string `json:"host"` 86 | Type string `json:"type"` 87 | MatchPattern string `json:"match_pattern"` 88 | Total int `json:"total"` 89 | Lines []LineResult `json:"lines"` 90 | } 91 | 92 | func (w *Watcher) Scan(page, pageSize int, reverse bool) (*ScanResult, error) { 93 | w.mutex.Lock() 94 | defer w.mutex.Unlock() 95 | 96 | file, scanner, err := w.initializeScanner() 97 | if err != nil { 98 | return nil, err 99 | } 100 | if file != nil { 101 | defer file.Close() 102 | } 103 | 104 | allLines, counts, err := w.collectMatchingLines(scanner) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | lines := w.paginateLines(allLines, page, pageSize, reverse) 110 | 111 | AppendGeneralInfo(&lines) 112 | return &ScanResult{ 113 | FilePath: w.filePath, 114 | Host: w.sshHost, 115 | MatchPattern: w.matchPattern, 116 | Total: counts, 117 | Lines: lines, 118 | }, nil 119 | } 120 | 121 | func (w *Watcher) initializeScanner() (*os.File, *bufio.Scanner, error) { 122 | if w.isRemote { 123 | return w.initializeRemoteScanner() 124 | } 125 | 126 | file, err := os.Open(w.filePath) 127 | if err != nil { 128 | return nil, nil, err 129 | } 130 | 131 | fileInfo, err := file.Stat() 132 | if err != nil { 133 | return nil, nil, err 134 | } 135 | if fileInfo.Size() == 0 { 136 | return file, bufio.NewScanner(file), nil 137 | } 138 | 139 | buffer := make([]byte, 2) 140 | if _, err := file.Read(buffer); err != nil { 141 | return nil, nil, err 142 | } 143 | _, err = file.Seek(0, 0) 144 | if err != nil { 145 | return nil, nil, err 146 | } 147 | 148 | if IsGzip(buffer) { 149 | gzipReader, err := gzip.NewReader(file) 150 | if err != nil { 151 | return nil, nil, err 152 | } 153 | return file, bufio.NewScanner(gzipReader), nil 154 | } 155 | 156 | return file, bufio.NewScanner(file), nil 157 | } 158 | 159 | func (w *Watcher) initializeRemoteScanner() (*os.File, *bufio.Scanner, error) { 160 | sshConfig := SSHConfig{ 161 | Host: w.sshHost, 162 | Port: w.sshPort, 163 | } 164 | session, err := NewSession(&sshConfig) 165 | if err != nil { 166 | return nil, nil, err 167 | } 168 | defer session.Close() 169 | 170 | var b bytes.Buffer 171 | session.Stdout = &b 172 | if err := session.Run(fmt.Sprintf("cat %s", w.filePath)); err != nil { 173 | if err.Error() != ErrorMsgSessionAlreadyStarted { 174 | return nil, nil, err 175 | } 176 | } 177 | 178 | scanner := bufio.NewScanner(strings.NewReader(b.String())) 179 | 180 | return nil, scanner, nil 181 | } 182 | 183 | func (w *Watcher) collectMatchingLines(scanner *bufio.Scanner) ([]LineResult, int, error) { 184 | re, err := regexp.Compile(w.matchPattern) 185 | if err != nil { 186 | return nil, 0, err 187 | } 188 | 189 | var reIgnore *regexp.Regexp 190 | if w.ignorePattern != "" { 191 | reIgnore, err = regexp.Compile(w.ignorePattern) 192 | if err != nil { 193 | return nil, 0, err 194 | } 195 | } 196 | 197 | var allLines []LineResult 198 | lineNumber := 0 199 | counts := 0 200 | 201 | for scanner.Scan() { 202 | line := scanner.Text() 203 | line = stripansi.Strip(line) 204 | lineNumber++ 205 | if reIgnore != nil && reIgnore.MatchString(line) { 206 | continue 207 | } 208 | if re.MatchString(line) { 209 | allLines = append(allLines, LineResult{ 210 | LineNumber: lineNumber, 211 | Content: line, 212 | }) 213 | counts++ 214 | } 215 | } 216 | 217 | if err := scanner.Err(); err != nil { 218 | return nil, 0, err 219 | } 220 | 221 | return allLines, counts, nil 222 | } 223 | 224 | func (w *Watcher) paginateLines(allLines []LineResult, page, pageSize int, reverse bool) []LineResult { 225 | var start, end int 226 | if reverse { 227 | start = len(allLines) - (page * pageSize) 228 | if start < 0 { 229 | start = 0 230 | } 231 | end = start + pageSize 232 | if end > len(allLines) { 233 | end = len(allLines) 234 | } 235 | } else { 236 | start = (page - 1) * pageSize 237 | end = start + pageSize 238 | if end > len(allLines) { 239 | end = len(allLines) 240 | } 241 | } 242 | 243 | if start < len(allLines) { 244 | return allLines[start:end] 245 | } 246 | 247 | return []LineResult{} 248 | } 249 | -------------------------------------------------------------------------------- /pkg/watcher_test.go: -------------------------------------------------------------------------------- 1 | package pkg 2 | 3 | import ( 4 | "compress/gzip" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | // TestNewWatcher tests the NewWatcher function 14 | func TestNewWatcher(t *testing.T) { 15 | watcher, err := NewWatcher("testfile.log", "ERROR", "", false, "", "", "", "", "") 16 | assert.NoError(t, err) 17 | assert.NotNil(t, watcher) 18 | assert.Equal(t, "testfile.log", watcher.filePath) 19 | assert.Equal(t, "ERROR", watcher.matchPattern) 20 | } 21 | 22 | // TestWatcher_Scan tests the Scan method of the Watcher struct 23 | func TestWatcher_Scan(t *testing.T) { 24 | dir := t.TempDir() 25 | 26 | // Create a temporary log file 27 | logFile := filepath.Join(dir, "test.log") 28 | content := `INFO Starting service 29 | ERROR An error occurred 30 | INFO Service running 31 | ERROR Another error occurred` 32 | err := os.WriteFile(logFile, []byte(content), 0600) 33 | assert.NoError(t, err) 34 | 35 | // Create the Watcher 36 | watcher, err := NewWatcher(logFile, "ERROR", "", false, "", "", "", "", "") 37 | assert.NoError(t, err) 38 | 39 | // Run the Scan method 40 | result, err := watcher.Scan(1, 10, false) 41 | assert.NoError(t, err) 42 | assert.NotNil(t, result) 43 | assert.Equal(t, 2, result.Total) 44 | assert.Len(t, result.Lines, 2) 45 | assert.Equal(t, 2, result.Lines[0].LineNumber) 46 | assert.Equal(t, "ERROR An error occurred", result.Lines[0].Content) 47 | assert.Equal(t, 4, result.Lines[1].LineNumber) 48 | assert.Equal(t, "ERROR Another error occurred", result.Lines[1].Content) 49 | } 50 | 51 | // TestWatcher_InitializeScanner tests the initializeScanner method of the Watcher struct 52 | func TestWatcher_InitializeScanner(t *testing.T) { 53 | dir := t.TempDir() 54 | 55 | // Create a temporary gzip log file 56 | logFile := filepath.Join(dir, "test.log.gz") 57 | var buf strings.Builder 58 | gz := gzip.NewWriter(&buf) 59 | _, err := gz.Write([]byte("INFO Starting service\nERROR An error occurred\n")) 60 | assert.NoError(t, err) 61 | assert.NoError(t, gz.Close()) 62 | err = os.WriteFile(logFile, []byte(buf.String()), 0600) 63 | assert.NoError(t, err) 64 | 65 | // Create the Watcher 66 | watcher, err := NewWatcher(logFile, "ERROR", "", false, "", "", "", "", "") 67 | assert.NoError(t, err) 68 | 69 | // Initialize the scanner 70 | file, scanner, err := watcher.initializeScanner() 71 | assert.NoError(t, err) 72 | assert.NotNil(t, file) 73 | assert.NotNil(t, scanner) 74 | 75 | // Read the lines 76 | var lines []string 77 | for scanner.Scan() { 78 | lines = append(lines, scanner.Text()) 79 | } 80 | assert.NoError(t, scanner.Err()) 81 | assert.Equal(t, 2, len(lines)) 82 | assert.Equal(t, "INFO Starting service", lines[0]) 83 | assert.Equal(t, "ERROR An error occurred", lines[1]) 84 | } 85 | 86 | // TestWatcher_CollectMatchingLines tests the collectMatchingLines method of the Watcher struct 87 | func TestWatcher_CollectMatchingLines(t *testing.T) { 88 | // Create a temporary log file 89 | dir := t.TempDir() 90 | logFile := filepath.Join(dir, "test.log") 91 | content := `INFO Starting service 92 | ERROR An error occurred 93 | INFO Service running 94 | ERROR Another error occurred` 95 | err := os.WriteFile(logFile, []byte(content), 0600) 96 | assert.NoError(t, err) 97 | 98 | // Create the Watcher 99 | watcher, err := NewWatcher(logFile, "ERROR", "", false, "", "", "", "", "") 100 | assert.NoError(t, err) 101 | 102 | // Initialize the scanner 103 | file, scanner, err := watcher.initializeScanner() 104 | assert.NoError(t, err) 105 | defer file.Close() 106 | 107 | // Collect matching lines 108 | lines, counts, err := watcher.collectMatchingLines(scanner) 109 | assert.NoError(t, err) 110 | assert.Equal(t, 2, counts) 111 | assert.Len(t, lines, 2) 112 | assert.Equal(t, 2, lines[0].LineNumber) 113 | assert.Equal(t, "ERROR An error occurred", lines[0].Content) 114 | assert.Equal(t, 4, lines[1].LineNumber) 115 | assert.Equal(t, "ERROR Another error occurred", lines[1].Content) 116 | } 117 | --------------------------------------------------------------------------------