├── .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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
36 |
37 |
38 |
39 |
41 |
42 |
43 |
44 |
93 |
94 |
95 |
96 |
97 |
98 |
113 |
114 |
115 |
116 |
117 |
119 |
120 |
128 |
129 |
130 |
137 |
138 |
139 |
147 |
148 |
149 |
163 |
164 |
165 |
166 |
167 |
168 |
170 |
171 |
172 |
173 |
174 |
175 |
176 | -
177 |
180 |
218 |
219 |
220 |
221 |
222 |
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 |
36 |
37 |
41 |
42 |
43 |
47 |
48 | LINE No
49 | |
50 |
51 | Level |
54 | Device |
57 | Time |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
70 |
71 |
72 |
73 | STDIN
74 |
75 | |
76 |
77 |
78 |
79 |
80 |
84 | |
86 |
87 |
88 | Success
92 |
93 |
94 | Info
98 |
99 |
100 | Error
104 |
105 |
106 | Warn
110 |
111 |
112 | Danger
116 |
117 |
118 | Debug
122 |
123 |
124 | Line
128 |
129 | |
130 |
131 |
132 |
149 |
150 |
151 |
168 |
169 |
170 |
184 |
185 |
186 |
203 |
204 |
205 | |
206 | |
207 | |
208 |
209 |
210 |
211 |
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 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
--------------------------------------------------------------------------------