diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f742c51 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "chore(gha)" + pull-request-branch-name: + separator: "/" + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "monthly" + commit-message: + prefix: "chore(gomod)" + pull-request-branch-name: + separator: "/" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..cfed771 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,34 @@ +name: check-json-log-viewer + +on: + push: + branches: + - main + pull_request: + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20.6' + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v3 + + - name: Build + run: make build + + - name: Lint + run: make lint + + - name: Test + run: make test + + - uses: shogo82148/actions-goveralls@v1 + with: + path-to-profile: coverage.out diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..75c71fa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,26 @@ +name: release-json-log-viewer + +on: + push: + tags: + - "*" + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.20.6' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v4 + with: + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GH_PAT }} diff --git a/.github/workflows/semantic.yaml b/.github/workflows/semantic.yaml new file mode 100644 index 0000000..5a50b59 --- /dev/null +++ b/.github/workflows/semantic.yaml @@ -0,0 +1,14 @@ +name: check-pr-semantic + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75aa064 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin + +# IDE +.vscode + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +vendor/ diff --git a/.golangci.json b/.golangci.json new file mode 100644 index 0000000..4b0e305 --- /dev/null +++ b/.golangci.json @@ -0,0 +1,45 @@ +{ + "linters": { + "enable-all": true, + "disable": [ + "lll", + "gochecknoglobals", + "bodyclose", + "wsl", + "funlen", + "maligned", + "exhaustivestruct", + "gci", + "wrapcheck", + "varnamelen", + "testpackage", + "exhaustive", + "gomnd", + "thelper", + "paralleltest", + "tagliatelle", + "scopelint", + "golint", + "interfacer", + "nonamedreturns", + "exhaustruct", + "nolintlint", + "deadcode", + "wastedassign", + "structcheck", + "varcheck", + "ifshort", + "nosnakecase", + "rowserrcheck", + "depguard", + "ireturn", + "gomoddirectives" + ] + }, + "linters-settings": { + "goimports": { + "local-prefixes": "github.com/hedhyw/json-log-viewer/" + }, + "revive": {} + } +} \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9620a89 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +GOLANG_CI_LINT_VER:=v1.53.3 +OUT_BIN?=${PWD}/bin/jlv +COVER_PACKAGES=./... +VERSION?=${shell git describe --tags} + +run: + @echo "building ${VERSION}" + go run ./cmd/jlv assets/example.log +.PHONY: build + +build: + @echo "building ${VERSION}" + go build \ + -o ${OUT_BIN} \ + --ldflags "-s -w -X main.version=${VERSION}" \ + ./cmd/jlv +.PHONY: build + +install: + go install ./cmd/jlv +.PHONY: install + +lint: bin/golangci-lint + ./bin/golangci-lint run +.PHONY: lint + +test: + go test \ + -coverpkg=${COVER_PACKAGES} \ + -covermode=count \ + -coverprofile=coverage.out \ + ./... + go tool cover -func=coverage.out +.PHONY: test + +vendor: + go mod tidy + go mod vendor +.PHONY: vendor + +bin/golangci-lint: + curl \ + -sSfL \ + https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh \ + | sh -s $(GOLANG_CI_LINT_VER) \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e6635f --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# JSON Log Viewer + +![Version](https://img.shields.io/github/v/tag/hedhyw/json-log-viewer) +![Build Status](https://github.com/hedhyw/json-log-viewer/actions/workflows/check.yml/badge.svg) +[![Go Report Card](https://goreportcard.com/badge/github.com/hedhyw/json-log-viewer)](https://goreportcard.com/report/github.com/hedhyw/json-log-viewer) +[![Coverage Status](https://coveralls.io/repos/github/hedhyw/json-log-viewer/badge.svg?branch=main)](https://coveralls.io/github/hedhyw/json-log-viewer?branch=main) + +It is an interactive tool for viewing and analyzing complex log files with structured JSON logs. + +![Animation](./assets/animation.webp) + +Main features: +1. It is interactive. +2. Is shows similified log records. +3. It is possible to see the full prettified JSON after clicking. +4. It includes non-JSON logs as they are. +5. It understands different field names. +6. It supports case-insensitive filtering. +7. It is simple. + +It uses [antonmedv/fx](https://github.com/antonmedv/fx) for viewing JSON and [charmbracelet/bubbletea](https://github.com/charmbracelet/bubbletea) for terminal UI. The tool is inspired by the project [json-log-viewer](https://github.com/gistia/json-log-viewer) which is unfortunately outdated. + +## Table of content + +- [JSON Log Viewer](#json-log-viewer) + - [Table of content](#table-of-content) + - [Usage](#usage) + - [Install](#install) + - [MacOS/Linux HomeBrew](#macoslinux-homebrew) + - [Go](#go) + - [Package](#package) + - [Standalone Binary](#standalone-binary) + - [Source](#source) + - [Roadmap](#roadmap) + - [Resources](#resources) + - [License](#license) + + +## Usage + +```sh +jlv file.json +``` + +| Key | Action | +| ------ | -------------- | +| Enter | Open/Close log | +| F | Filter | +| Ctrl+C | Exit | +| Esc | Back | +| ↑↓ | Navigation | + +## Install + +### MacOS/Linux HomeBrew + +```sh +brew install hedhyw/main/json-log-viewer +``` + +### Go + +```bash +go install github.com/hedhyw/json-log-viewer/cmd/jlv@latest +``` + +### Package + +Latest DEB and RPM packages are available on [the releases page](https://github.com/hedhyw/json-log-viewer/releases/latest). + +### Standalone Binary + +Download latest archive `*.tar.gz` for your target platform from [the releases page](https://github.com/hedhyw/json-log-viewer/releases/latest) and extract it to `/usr/local/bin/jlv`. Add this path to `PATH` environment. + +### Source + +``` +git clone git@github.com:hedhyw/json-log-viewer.git +cd json-log-viewer +make build +cp ./bin/jlv /usr/local/bin +chmod +x /usr/local/bin/jlv +``` + +## Roadmap + +- Accept stream of logs. +- Add colors to log levels. +- Add a configuration file (similar to `.json-log-viewer`). +- Convert number timestamps. + +## Resources + +Alternatives: +- [mightyguava/jl](https://github.com/mightyguava/jl) - Pretty Viewer for JSON logs. +- [pamburus/hl](https://github.com/pamburus/hl) - A log viewer that translates JSON logs into human-readable representation. +- [json-log-viewer](https://github.com/gistia/json-log-viewer) - Powerful terminal based viewer for JSON logs using ncurses. + +## License + +[MIT License](LICENSE). diff --git a/assets/animation.webp b/assets/animation.webp new file mode 100644 index 0000000..216cec3 Binary files /dev/null and b/assets/animation.webp differ diff --git a/assets/assets.go b/assets/assets.go new file mode 100644 index 0000000..497f959 --- /dev/null +++ b/assets/assets.go @@ -0,0 +1,17 @@ +package assets + +import ( + _ "embed" +) + +//go:embed example.log +var exampleJSONLog []byte + +// ExampleJSONLog returns a copy of the file "example.log". +func ExampleJSONLog() []byte { + target := make([]byte, len(exampleJSONLog)) + + copy(target, exampleJSONLog) + + return target +} diff --git a/assets/assets_test.go b/assets/assets_test.go new file mode 100644 index 0000000..5bf622c --- /dev/null +++ b/assets/assets_test.go @@ -0,0 +1,18 @@ +package assets_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/hedhyw/json-log-viewer/assets" +) + +func TestExampleJSONLog(t *testing.T) { + t.Parallel() + + content := assets.ExampleJSONLog() + if assert.NotEmpty(t, content) { + assert.Contains(t, string(content), "{") + } +} diff --git a/assets/example.log b/assets/example.log new file mode 100644 index 0000000..5498220 --- /dev/null +++ b/assets/example.log @@ -0,0 +1,46 @@ +{"time":1,"level":"INFO","msg": "Nothing happens unless first we dream.","author": "Carl Sandburg"} +{"timestamp":2,"level":"INFO","message": "Well begun is half done.","author": "Aristotle"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Life is a learning experience, only if you learn.","author": "Yogi Berra"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Self-complacency is fatal to progress.","author": "Margaret Sangster"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Peace comes from within. Do not seek it without.","author": "Buddha"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "What you give is what you get.","author": "Byron Pulsifer"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "We can only learn to love by loving.","author": "Iris Murdoch"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Life is change. Growth is optional. Choose wisely.","author": "Karen Clark"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "You'll see it when you believe it.","author": "Wayne Dyer"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Today is the tomorrow we worried about yesterday.","author": null} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "It's easier to see the mistakes on someone else's paper.","author": null} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Every man dies. Not every man really lives.","author": null} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "To lead people walk behind them.","author": "Lao Tzu"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Having nothing, nothing can he lose.","author": "William Shakespeare"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Trouble is only opportunity in work clothes.","author": "Henry J. Kaiser"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "A rolling stone gathers no moss.","author": "Publilius Syrus"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Ideas are the beginning points of all fortunes.","author": "Napoleon Hill"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Everything in life is luck.","author": "Donald Trump"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Doing nothing is better than being busy doing nothing.","author": "Lao Tzu"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Trust yourself. You know more than you think you do.","author": "Benjamin Spock"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Study the past, if you would divine the future.","author": "Confucius"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "The day is already blessed, find peace within it.","author": null} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "From error to error one discovers the entire truth.","author": "Sigmund Freud"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Well done is better than well said.","author": "Benjamin Franklin"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Bite off more than you can chew, then chew it.","author": "Ella Williams"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Work out your own salvation. Do not depend on others.","author": "Buddha"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "One today is worth two tomorrows.","author": "Benjamin Franklin"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Once you choose hope, anythings possible.","author": "Christopher Reeve"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "God always takes the simplest way.","author": "Albert Einstein"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "One fails forward toward success.","author": "Charles Kettering"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "From small beginnings come great things.","author": null} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Learning is a treasure that will follow its owner everywhere","author": "Chinese proverb"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Be as you wish to seem.","author": "Socrates"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "The world is always in movement.","author": "V. Naipaul"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Never mistake activity for achievement.","author": "John Wooden"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "What worries you masters you.","author": "Haddon Robinson"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "One faces the future with ones past.","author": "Pearl Buck"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Goals are the fuel in the furnace of achievement.","author": "Brian Tracy"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "Who sows virtue reaps honour.","author": "Leonardo da Vinci"} +{"time":"1970-01-01T00:00:00.00","level":"FATAL","message": "Be the chief but never the lord.","author": "Lao Tzu"} +{"time":"1970-01-01T00:00:00.00","level":"PANIC","message": "Fate is in your hands and no one elses","author": "Byron Pulsifer"} +{"time":"1970-01-01T00:00:00.00","level":"ERROR","message": "Genius is one percent inspiration and ninety-nine percent perspiration.","author": "Thomas Edison"} +{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "You can observe a lot just by watching.","author": "Yogi Berra"} +{"time":"1970-01-01T00:00:00.00","level":"DEBUG","message": "A house divided against itself cannot stand.","author": "Abraham Lincoln"} +{"time":"1970-01-01T00:00:00.00","level":"TRACE","message": "Difficulties increase the nearer we get to the goal.","author": "Johann Wolfgang von Goethe"} +plain text log diff --git a/cmd/jlv/main.go b/cmd/jlv/main.go new file mode 100644 index 0000000..869dc40 --- /dev/null +++ b/cmd/jlv/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/hedhyw/json-log-viewer/internal/app" +) + +func main() { + if len(os.Args) != 2 { + fatalf("Invalid arguments, usage: %s file.log\n", os.Args[0]) + } + + appModel := app.NewModel(os.Args[1]) + program := tea.NewProgram(appModel, tea.WithAltScreen()) + + if _, err := program.Run(); err != nil { + fatalf("Error running program: %s\n", err) + } +} + +func fatalf(message string, args ...any) { + fmt.Fprintf(os.Stderr, message, args...) + os.Exit(1) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4733046 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module github.com/hedhyw/json-log-viewer + +go 1.20 + +replace github.com/antonmedv/fx => github.com/hedhyw/fx v0.0.1 + +require ( + github.com/antonmedv/fx v0.0.0-20230706101337-4fec4e492a52 + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.7.1 + github.com/muesli/reflow v0.3.0 + github.com/stretchr/testify v1.8.4 + github.com/valyala/fastjson v1.6.4 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mazznoer/colorgrad v0.9.1 // indirect + github.com/mazznoer/csscolorparser v0.1.3 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.4 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.7.0 // indirect + golang.org/x/term v0.7.0 // indirect + golang.org/x/text v0.9.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c0ce3f3 --- /dev/null +++ b/go.sum @@ -0,0 +1,62 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hedhyw/fx v0.0.1 h1:h1jJaDnJ6qewSKiD7yooAGZjQwS+yazFcoRgtWV0Rq8= +github.com/hedhyw/fx v0.0.1/go.mod h1:mT/W/Ln5xzLNEh+wGWAsPITPpQV5w6ne7klykEUS78w= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mazznoer/colorgrad v0.9.1 h1:MB80JYVndKWSMEM1beNqnuOowWGhoQc3DXWXkFp6JlM= +github.com/mazznoer/colorgrad v0.9.1/go.mod h1:WX2R9wt9B47+txJZVVpM9LY+LAGIdi4lTI5wIyreDH4= +github.com/mazznoer/csscolorparser v0.1.2/go.mod h1:Aj22+L/rYN/Y6bj3bYqO3N6g1dtdHtGfQ32xZ5PJQic= +github.com/mazznoer/csscolorparser v0.1.3 h1:vug4zh6loQxAUxfU1DZEu70gTPufDPspamZlHAkKcxE= +github.com/mazznoer/csscolorparser v0.1.3/go.mod h1:Aj22+L/rYN/Y6bj3bYqO3N6g1dtdHtGfQ32xZ5PJQic= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= +github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/action.go b/internal/app/action.go new file mode 100644 index 0000000..b6d2a80 --- /dev/null +++ b/internal/app/action.go @@ -0,0 +1,95 @@ +package app + +import ( + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + + "github.com/hedhyw/json-log-viewer/internal/pkg/widgets" +) + +func (m Model) hideJSON() (Model, tea.Cmd) { + m.jsonView = nil + + return m, nil +} + +func (m Model) showJSON() (Model, tea.Cmd) { + cursor := m.table.Cursor() + if cursor < 0 || cursor >= len(m.filteredLogEntries) { + return m, nil + } + + logEntry := m.filteredLogEntries[cursor] + + jsonViewModel, cmd := widgets.NewJSONViewModel(logEntry.Line, m.lastWindowSize) + m.jsonView = jsonViewModel + + return m, cmd +} + +func (m Model) quit() (Model, tea.Cmd) { + return m, tea.Quit +} + +func (m Model) showFilter() (tea.Model, tea.Cmd) { + if !m.IsTableShown() { + return nil, nil + } + + m.textInputShown = true + m.textInput = textinput.New() + m.textInput.Focus() + m.textInput.Prompt = "Filter >" + m.table.Blur() + + return m, nil +} + +func (m Model) toggleLogEntity() (Model, tea.Cmd) { + if m.IsJSONShown() { + return m.hideJSON() + } + + return m.showJSON() +} + +func (m Model) clearFilter() (Model, tea.Cmd) { + m.textInput.SetValue("") + + return m.applyFilter() +} + +func (m Model) back() (Model, tea.Cmd) { + if m.IsFilterShown() { + return m.clearFilter() + } + + if m.IsJSONShown() { + return m.hideJSON() + } + + if m.IsFiltered() { + return m.clearFilter() + } + + return m.quit() +} + +func (m Model) applyFilter() (Model, tea.Cmd) { + m.textInputShown = false + m.table.Focus() + + term := m.textInput.Value() + + if term == "" { + return m, func() tea.Msg { + return m.allLogEntries + } + } + + m.table.GotoTop() + + return m, func() tea.Msg { + return m.allLogEntries.Filter(term) + } +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..9690490 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,92 @@ +package app + +import ( + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/hedhyw/json-log-viewer/internal/pkg/source" +) + +// Model of the application. +type Model struct { + baseStyle lipgloss.Style + footerStyle lipgloss.Style + + fileLogPath string + + table table.Model + allLogEntries source.LogEntries + + filteredLogEntries source.LogEntries + + lastWindowSize tea.WindowSizeMsg + jsonView tea.Model + + textInputShown bool + textInput textinput.Model + + err error +} + +// NewModel initializes a new application model. It accept the path +// to the file with logs. +func NewModel(path string) Model { + tableLogs := table.New( + table.WithColumns(getColumns(100)), + table.WithFocused(true), + table.WithHeight(7), + ) + + tableLogs.SetStyles(getTableStyles()) + + return Model{ + baseStyle: getBaseStyle(), + footerStyle: getFooterStyle(), + + fileLogPath: path, + table: tableLogs, + + err: nil, + allLogEntries: nil, + filteredLogEntries: nil, + + textInputShown: false, + textInput: textinput.Model{}, + + lastWindowSize: tea.WindowSizeMsg{}, + jsonView: nil, + } +} + +// Init implements team.Model interface. +func (m Model) Init() tea.Cmd { + return source.LoadLogsFromFile(m.fileLogPath) +} + +// Update implements team.Model interface. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m = m.handleWindowSizeMsg(msg) + case source.LogEntries: + m = m.handleLogEntriesMsg(msg) + case error: + m = m.handleErrorMsg(msg) + + return m, nil + case tea.KeyMsg: + newModel, cmd := m.handleKeyMsg(msg) + if newModel != nil || cmd != nil { + return newModel, cmd + } + } + + return m.handleUpdateInViews(msg) +} + +// View implements team.Model interface. +func (m Model) View() string { + return m.renderViews() +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..c356da5 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,126 @@ +package app_test + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + + "github.com/hedhyw/json-log-viewer/assets" + "github.com/hedhyw/json-log-viewer/internal/app" + "github.com/hedhyw/json-log-viewer/internal/pkg/tests" +) + +func TestAppViewFiltered(t *testing.T) { + const ( + termIncluded = "included" + termExcluded = "excluded" + ) + + const jsonFile = ` + {"time":"1970-01-01T00:00:00.00","level":"INFO","message": "` + termIncluded + `"} + {"time":"1970-01-01T00:00:00.00","level":"INFO","message": "` + termExcluded + `"} + ` + + appModel := newTestModel(t, []byte(jsonFile)) + + rendered := appModel.View() + assert.Contains(t, rendered, termIncluded) + assert.Contains(t, rendered, termExcluded) + + // Open filter. + appModel, _ = toAppModel(appModel.Update(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune{'f'}, + })) + assert.True(t, appModel.IsFilterShown(), appModel.View()) + + // Write term to search by. + appModel, _ = toAppModel(appModel.Update(tea.KeyMsg{ + Type: tea.KeyRunes, + Runes: []rune(termIncluded), + })) + + // Filter. + appModel, cmd := toAppModel(appModel.Update(tea.KeyMsg{ + Type: tea.KeyEnter, + })) + assert.False(t, appModel.IsFilterShown(), appModel.View()) + + appModel, _ = toAppModel(appModel.Update(cmd())) + + // Assert. + if assert.True(t, appModel.IsFiltered()) { + rendered = appModel.View() + assert.Contains(t, rendered, termIncluded) + assert.NotContains(t, rendered, termExcluded) + } +} + +func TestAppViewMainScreen(t *testing.T) { + const jsonFile = `{"time":"1970-01-01T00:00:00.00","level":"INFO","message": "test"}` + + appModel := newTestModel(t, []byte(jsonFile)) + + if assert.True(t, appModel.IsTableShown()) { + rendered := appModel.View() + assert.Contains(t, rendered, "info") + assert.Contains(t, rendered, "1970-01-01T00:00:00.00") + assert.Contains(t, rendered, "test") + } +} + +func TestAppEnterAndCloseJSONView(t *testing.T) { + appModel := newTestModel(t, assets.ExampleJSONLog()) + + appModel, _ = toAppModel(appModel.Update(tea.KeyMsg{Type: tea.KeyEnter})) + assert.True(t, appModel.IsJSONShown()) + + appModel, _ = toAppModel(appModel.Update(tea.KeyMsg{Type: tea.KeyEnter})) + assert.False(t, appModel.IsJSONShown()) +} + +func TestAppQuit(t *testing.T) { + t.Parallel() + + appModel := newTestModel(t, assets.ExampleJSONLog()) + + t.Run("ctrl_and_c", func(t *testing.T) { + t.Parallel() + + _, cmd := toAppModel(appModel.Update(tea.KeyMsg{Type: tea.KeyCtrlC})) + + if assert.NotNil(t, cmd) { + assert.Equal(t, tea.Quit(), cmd()) + } + }) + + t.Run("esc", func(t *testing.T) { + t.Parallel() + + _, cmd := toAppModel(appModel.Update(tea.KeyMsg{Type: tea.KeyEsc})) + + if assert.NotNil(t, cmd) { + assert.Equal(t, tea.Quit(), cmd()) + } + }) +} + +func newTestModel(tb testing.TB, content []byte) app.Model { + tb.Helper() + + testFile := tests.RequireCreateFile(tb, content) + + appModel := app.NewModel(testFile) + cmd := appModel.Init() + + appModel, _ = toAppModel(appModel.Update(cmd())) + + return appModel +} + +func toAppModel(teaModel tea.Model, cmd tea.Cmd) (app.Model, tea.Cmd) { + appModel, _ := teaModel.(app.Model) + + return appModel, cmd +} diff --git a/internal/app/handler.go b/internal/app/handler.go new file mode 100644 index 0000000..95782a7 --- /dev/null +++ b/internal/app/handler.go @@ -0,0 +1,62 @@ +package app + +import ( + tea "github.com/charmbracelet/bubbletea" + + "github.com/hedhyw/json-log-viewer/internal/pkg/source" +) + +func (m Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + return m.back() + case "q", "ctrl+c": + return m.quit() + case "enter": + return m.handleEnter() + case "f": + return m.showFilter() + default: + return nil, nil + } +} + +func (m Model) handleEnter() (tea.Model, tea.Cmd) { + if m.IsFilterShown() { + return m.applyFilter() + } + + if m.IsTableShown() || m.IsJSONShown() { + return m.toggleLogEntity() + } + + return nil, nil +} + +func (m Model) handleWindowSizeMsg(msg tea.WindowSizeMsg) Model { + x, y := m.baseStyle.GetFrameSize() + m.table.SetWidth(msg.Width - x*2) + m.table.SetHeight(msg.Height - y*2 - footerSize) + m.table.SetColumns(getColumns(m.table.Width() - 10)) + m.lastWindowSize = msg + + return m +} + +func (m Model) handleLogEntriesMsg(msg source.LogEntries) Model { + m.table.SetRows(msg.Rows()) + + if len(m.allLogEntries) == 0 { + m.allLogEntries = msg + } + + m.filteredLogEntries = msg + + return m +} + +func (m Model) handleErrorMsg(err error) Model { + m.err = err + + return m +} diff --git a/internal/app/helper.go b/internal/app/helper.go new file mode 100644 index 0000000..f59305f --- /dev/null +++ b/internal/app/helper.go @@ -0,0 +1,16 @@ +package app + +import "github.com/charmbracelet/bubbles/table" + +func getColumns(width int) []table.Column { + const ( + widthTime = 30 + widthLevel = 10 + ) + + return []table.Column{ + {Title: "Time", Width: widthTime}, + {Title: "Level", Width: widthLevel}, + {Title: "Message", Width: width - widthTime - widthLevel}, + } +} diff --git a/internal/app/property.go b/internal/app/property.go new file mode 100644 index 0000000..b4cbdba --- /dev/null +++ b/internal/app/property.go @@ -0,0 +1,26 @@ +package app + +// IsErrorShown indicates that err is shown on the screen. +func (m Model) IsErrorShown() bool { + return m.err != nil +} + +// IsJSONShown indicates that extended JSON view is shown on the screen. +func (m Model) IsJSONShown() bool { + return m.jsonView != nil +} + +// IsJSONShown indicates that the main list is shown on the screen. +func (m Model) IsTableShown() bool { + return !m.IsJSONShown() +} + +// IsFilterShown indicates that the filter is shown on the screen. +func (m Model) IsFilterShown() bool { + return m.textInputShown +} + +// IsFiltered indicates that the results are filtered. +func (m Model) IsFiltered() bool { + return len(m.allLogEntries) != len(m.filteredLogEntries) +} diff --git a/internal/app/style.go b/internal/app/style.go new file mode 100644 index 0000000..47a2b8c --- /dev/null +++ b/internal/app/style.go @@ -0,0 +1,31 @@ +package app + +import ( + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" +) + +func getTableStyles() table.Styles { + tableStyles := table.DefaultStyles() + tableStyles.Header = tableStyles.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(false) + tableStyles.Selected = tableStyles.Selected. + Foreground(lipgloss.Color("229")). + Background(lipgloss.Color("57")). + Bold(false) + + return tableStyles +} + +func getBaseStyle() lipgloss.Style { + return lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("240")) +} + +func getFooterStyle() lipgloss.Style { + return lipgloss.NewStyle().Height(footerSize).PaddingLeft(2) +} diff --git a/internal/app/view.go b/internal/app/view.go new file mode 100644 index 0000000..615e6f2 --- /dev/null +++ b/internal/app/view.go @@ -0,0 +1,56 @@ +package app + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + defaultFooter = "[Ctrl+C] Exit; [Esc] Back; [Enter] Open/Hide; [↑↓] Navigation; [F] Filter" + + footerSize = 1 +) + +func (m Model) renderViews() string { + if m.IsJSONShown() { + return m.jsonView.View() + } + + if m.IsErrorShown() { + return fmt.Sprintf("something went wrong: %s", m.err) + } + + var footer string + + if m.IsFilterShown() { + footer = "\n" + m.textInput.View() + } else { + footer = "\n" + m.footerStyle.Render(defaultFooter) + } + + return m.baseStyle.Render(m.table.View()) + footer +} + +func (m Model) handleUpdateInViews(msg tea.Msg) (Model, tea.Cmd) { + var cmd tea.Cmd + + cmds := make([]tea.Cmd, 0, 3) + + if m.IsJSONShown() { + _, cmd := m.jsonView.Update(msg) + cmds = append(cmds, cmd) + } + + if m.IsTableShown() { + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + } + + if m.IsFilterShown() { + m.textInput, cmd = m.textInput.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} diff --git a/internal/pkg/source/entry.go b/internal/pkg/source/entry.go new file mode 100644 index 0000000..8844443 --- /dev/null +++ b/internal/pkg/source/entry.go @@ -0,0 +1,93 @@ +package source + +import ( + "bytes" + "encoding/json" + + "github.com/charmbracelet/bubbles/table" + "github.com/valyala/fastjson" +) + +// LogEntry is a single partly-parse record of the log. +type LogEntry struct { + Time string + Level Level + Message string + Line json.RawMessage +} + +// Row returns table.Row representation of the log entry. +func (e LogEntry) Row() table.Row { + return table.Row{ + e.Time, + string(e.Level), + e.Message, + } +} + +// LogEntries is a helper type definition for the slice of log entries. +type LogEntries []LogEntry + +// Filter filters entries by ignore case exact match. +func (entries LogEntries) Filter(term string) LogEntries { + if term == "" { + return entries + } + + termLower := bytes.ToLower([]byte(term)) + + filtered := make([]LogEntry, 0, len(entries)) + + for _, f := range entries { + if bytes.Contains(bytes.ToLower(f.Line), termLower) { + filtered = append(filtered, f) + } + } + + return filtered +} + +func (entries LogEntries) Reverse() LogEntries { + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + + return entries +} + +// Rows returns all table.Row by log entries. +func (entries LogEntries) Rows() []table.Row { + rows := make([]table.Row, len(entries)) + + for i, e := range entries { + rows[i] = e.Row() + } + + return rows +} + +// ParseLogEntry parses a single log entry from the json line. +func ParseLogEntry(line json.RawMessage) LogEntry { + var jsonParser fastjson.Parser + + lineToParse := make([]byte, len(line)) + copy(lineToParse, line) + line = lineToParse + + value, err := jsonParser.ParseBytes(lineToParse) + if err != nil { + return LogEntry{ + Line: line, + Time: "-", + Message: string(line), + Level: LevelUnknown, + } + } + + return LogEntry{ + Line: line, + Time: extractTime(value), + Message: extractMessage(value), + Level: extractLevel(value), + } +} diff --git a/internal/pkg/source/entry_test.go b/internal/pkg/source/entry_test.go new file mode 100644 index 0000000..1da1a93 --- /dev/null +++ b/internal/pkg/source/entry_test.go @@ -0,0 +1,240 @@ +package source_test + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/hedhyw/json-log-viewer/internal/pkg/source" + + "github.com/stretchr/testify/assert" +) + +func TestParseLogEntry(t *testing.T) { + t.Parallel() + + testCases := [...]struct { + Name string + JSON string + Assert func(tb testing.TB, logEntry source.LogEntry) + }{{ + Name: "plain_log", + JSON: "Hello World", + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "Hello World", logEntry.Message) + assert.Equal(t, source.LevelUnknown, logEntry.Level) + assert.Equal(t, "-", logEntry.Time) + }, + }, { + Name: "time_number", + JSON: `{"time":1}`, + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "1", logEntry.Time) + }, + }, { + Name: "timestamp_number", + JSON: `{"timestamp":1}`, + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "1", logEntry.Time) + }, + }, { + Name: "time_text", + JSON: `{"time":"1970-01-01T00:00:00.00"}`, + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "1970-01-01T00:00:00.00", logEntry.Time) + }, + }, { + Name: "timestamp_text", + JSON: `{"timestamp":"1970-01-01T00:00:00.00"}`, + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "1970-01-01T00:00:00.00", logEntry.Time) + }, + }, { + Name: "message", + JSON: `{"message":"message"}`, + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "message", logEntry.Message) + }, + }, { + Name: "msg", + JSON: `{"msg":"msg"}`, + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "msg", logEntry.Message) + }, + }, { + Name: "error", + JSON: `{"error":"error"}`, + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "error", logEntry.Message) + }, + }, { + Name: "err", + JSON: `{"err":"err"}`, + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "err", logEntry.Message) + }, + }, { + Name: "level", + JSON: `{"level":"INFO"}`, + Assert: func(tb testing.TB, logEntry source.LogEntry) { + tb.Helper() + + assert.Equal(t, "info", logEntry.Level.String()) + }, + }} + + for _, testCase := range testCases { + testCase := testCase + + t.Run(testCase.Name, func(t *testing.T) { + t.Parallel() + + actual := source.ParseLogEntry(json.RawMessage(testCase.JSON)) + testCase.Assert(t, actual) + }) + } +} + +func TestLogEntryRow(t *testing.T) { + t.Parallel() + + entry := getFakeLogEntry() + row := entry.Row() + + if assert.Len(t, row, 3) { + assert.Equal(t, entry.Time, row[0]) + assert.Equal(t, string(entry.Level), row[1]) + assert.Equal(t, entry.Message, row[2]) + } +} + +func TestLogEntriesRows(t *testing.T) { + t.Parallel() + + entries := source.LogEntries{ + getFakeLogEntry(), + getFakeLogEntry(), + getFakeLogEntry(), + } + rows := entries.Rows() + + if assert.Len(t, rows, len(entries)) { + for i, e := range entries { + assert.Equal(t, e.Row(), rows[i]) + } + } +} + +func TestLogEntriesReverse(t *testing.T) { + t.Parallel() + + t.Run("simple", func(t *testing.T) { + t.Parallel() + + original := source.LogEntries{ + getFakeLogEntry(), + getFakeLogEntry(), + getFakeLogEntry(), + } + + entries := make(source.LogEntries, len(original)) + copy(entries, original) + actual := entries.Reverse() + + assert.Equal(t, actual[0], original[2]) + assert.Equal(t, actual[1], original[1]) + assert.Equal(t, actual[2], original[0]) + }) + + t.Run("single", func(t *testing.T) { + t.Parallel() + + entries := source.LogEntries{ + getFakeLogEntry(), + } + + assert.Len(t, entries, 1) + }) + + t.Run("empty", func(t *testing.T) { + t.Parallel() + + entries := source.LogEntries{} + + assert.Empty(t, entries) + }) +} + +func getFakeLogEntry() source.LogEntry { + return source.LogEntry{ + Time: "time", + Level: source.LevelUnknown, + Message: "message", + Line: []byte(`{"hello":"world"}`), + } +} + +func TestLogEntriesFilter(t *testing.T) { + t.Parallel() + + term := "special MESSAGE to search by in the test: " + t.Name() + + logEntry := getFakeLogEntry() + logEntry.Message = term + logEntry.Line = json.RawMessage(`{"message": "` + term + `"}`) + + logEntries := source.LogEntries{ + getFakeLogEntry(), + logEntry, + getFakeLogEntry(), + } + + t.Run("all", func(t *testing.T) { + t.Parallel() + + assert.Len(t, logEntries.Filter(""), len(logEntries)) + }) + + t.Run("found_exact", func(t *testing.T) { + t.Parallel() + + filtered := logEntries.Filter(term) + if assert.Len(t, filtered, 1) { + assert.Equal(t, logEntry, filtered[0]) + } + }) + + t.Run("found_ignore_case", func(t *testing.T) { + t.Parallel() + + filtered := logEntries.Filter(strings.ToUpper(term)) + if assert.Len(t, filtered, 1) { + assert.Equal(t, logEntry, filtered[0]) + } + }) + + t.Run("not_found", func(t *testing.T) { + t.Parallel() + + filtered := logEntries.Filter(term + " - not found!") + assert.Empty(t, filtered) + }) +} diff --git a/internal/pkg/source/helper.go b/internal/pkg/source/helper.go new file mode 100644 index 0000000..281fc35 --- /dev/null +++ b/internal/pkg/source/helper.go @@ -0,0 +1,53 @@ +package source + +import ( + "strconv" + "strings" + + "github.com/valyala/fastjson" +) + +func extractTime(value *fastjson.Value) string { + timeValue := extractValue(value, "timestamp", "time", "t") + if timeValue != "" { + return strings.TrimSpace(timeValue) + } + + return "-" +} + +func extractLevel(value *fastjson.Value) Level { + level := extractValue(value, "level", "lvl") + if level != "" { + return Level(strings.TrimSpace(strings.ToLower(level))) + } + + return LevelUnknown +} + +func extractValue(value *fastjson.Value, keys ...string) string { + for _, k := range keys { + element := value.Get(k) + + text := string(element.GetStringBytes()) + if text != "" { + return text + } + + number := element.GetInt() + if number != 0 { + return strconv.Itoa(number) + } + } + + return "" +} + +func extractMessage(value *fastjson.Value) string { + message := extractValue(value, "message", "msg", "error", "err") + if message != "" { + return strings.TrimSpace(message) + } + + return strings.TrimSpace(value.String()) +} diff --git a/internal/pkg/source/level.go b/internal/pkg/source/level.go new file mode 100644 index 0000000..4552b54 --- /dev/null +++ b/internal/pkg/source/level.go @@ -0,0 +1,16 @@ +package source + +import "strings" + +// Level of the logs entity. +type Level string + +// String implement fmt.Stringer interface. +func (l Level) String() string { + return strings.ToLower(string(l)) +} + +// Possible log levels. +const ( + LevelUnknown Level = "none" +) diff --git a/internal/pkg/source/source.go b/internal/pkg/source/source.go new file mode 100644 index 0000000..94aa286 --- /dev/null +++ b/internal/pkg/source/source.go @@ -0,0 +1,40 @@ +package source + +import ( + "bufio" + "bytes" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +// LoadLogsFromFile loads json log entries from file. +func LoadLogsFromFile(path string) func() tea.Msg { + return func() (msg tea.Msg) { + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("opening: %w", err) + } + + defer file.Close() + + scanner := bufio.NewScanner(file) + + logEntries := make(LogEntries, 0, 256) + + for scanner.Scan() { + line := scanner.Bytes() + + if len(bytes.TrimSpace(line)) > 0 { + logEntries = append(logEntries, ParseLogEntry(line)) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("scanning: %w", err) + } + + return logEntries.Reverse() + } +} diff --git a/internal/pkg/source/source_test.go b/internal/pkg/source/source_test.go new file mode 100644 index 0000000..10d30f4 --- /dev/null +++ b/internal/pkg/source/source_test.go @@ -0,0 +1,37 @@ +package source_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/hedhyw/json-log-viewer/assets" + "github.com/hedhyw/json-log-viewer/internal/pkg/source" + "github.com/hedhyw/json-log-viewer/internal/pkg/tests" +) + +func TestLoadLogsFromFile(t *testing.T) { + t.Parallel() + + t.Run("ok", func(t *testing.T) { + t.Parallel() + + testFile := tests.RequireCreateFile(t, assets.ExampleJSONLog()) + + msg := source.LoadLogsFromFile(testFile)() + + logEntries, ok := msg.(source.LogEntries) + if assert.Truef(t, ok, "actual type: %T", msg) { + assert.NotEmpty(t, logEntries) + } + }) + + t.Run("not_found", func(t *testing.T) { + t.Parallel() + + msg := source.LoadLogsFromFile("not_found_for_" + t.Name())() + + _, ok := msg.(error) + assert.Truef(t, ok, "actual type: %T", msg) + }) +} diff --git a/internal/pkg/tests/tests.go b/internal/pkg/tests/tests.go new file mode 100644 index 0000000..d2d4cc9 --- /dev/null +++ b/internal/pkg/tests/tests.go @@ -0,0 +1,28 @@ +package tests + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// RequireCreateFile is a helper that create a temporary file and deletes +// it at the end of the test. +func RequireCreateFile(tb testing.TB, content []byte) string { + tb.Helper() + + f, err := os.CreateTemp("", "json_log_viewer_test") + require.NoError(tb, err) + + defer func() { assert.NoError(tb, f.Close()) }() + + _, err = f.Write(content) + require.NoError(tb, err) + + name := f.Name() + tb.Cleanup(func() { assert.NoError(tb, os.Remove(name)) }) + + return name +} diff --git a/internal/pkg/widgets/jsonview.go b/internal/pkg/widgets/jsonview.go new file mode 100644 index 0000000..b728934 --- /dev/null +++ b/internal/pkg/widgets/jsonview.go @@ -0,0 +1,33 @@ +package widgets + +import ( + "bytes" + "encoding/json" + + fxjson "github.com/antonmedv/fx/pkg/json" + fx "github.com/antonmedv/fx/pkg/model" + "github.com/antonmedv/fx/pkg/theme" + tea "github.com/charmbracelet/bubbletea" +) + +const themeFX = "1" + +// NewJSONViewModel creates a new JSON view widget if a content is the correct json, +// or plain text view otherwise. +func NewJSONViewModel(content []byte, lastWindowSize tea.WindowSizeMsg) (tea.Model, tea.Cmd) { + jsonDecoder := json.NewDecoder(bytes.NewReader(content)) + jsonDecoder.UseNumber() + + object, err := fxjson.Parse(jsonDecoder) + if err != nil { + return NewPlainLogModel(string(content), lastWindowSize) + } + + fxModel := fx.NewModel(object, fx.Config{ + FileName: "", + Theme: theme.Themes[themeFX], + ShowSize: true, + }) + + return fxModel.Update(lastWindowSize) +} diff --git a/internal/pkg/widgets/jsonview_test.go b/internal/pkg/widgets/jsonview_test.go new file mode 100644 index 0000000..71ee6e7 --- /dev/null +++ b/internal/pkg/widgets/jsonview_test.go @@ -0,0 +1,34 @@ +package widgets_test + +import ( + "testing" + + "github.com/hedhyw/json-log-viewer/internal/pkg/widgets" + + "github.com/stretchr/testify/assert" +) + +func TestNewJSONViewModel(t *testing.T) { + t.Parallel() + + t.Run("plain_text", func(t *testing.T) { + t.Parallel() + + model, _ := widgets.NewJSONViewModel([]byte(text), getFakeTeaWindowSizeMsg()) + + _, ok := model.(widgets.PlainLogModel) + assert.Truef(t, ok, "actual type: %T", model) + }) + + t.Run("json", func(t *testing.T) { + t.Parallel() + + model, _ := widgets.NewJSONViewModel( + []byte(`{"hello":"world"}`), + getFakeTeaWindowSizeMsg(), + ) + + _, ok := model.(widgets.PlainLogModel) + assert.Falsef(t, ok, "actual type: %T", model) + }) +} diff --git a/internal/pkg/widgets/plain.go b/internal/pkg/widgets/plain.go new file mode 100644 index 0000000..d86b170 --- /dev/null +++ b/internal/pkg/widgets/plain.go @@ -0,0 +1,62 @@ +package widgets + +import ( + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/muesli/reflow/wordwrap" +) + +// PlainLogModel is a widget that shows multiline text in a viewport. +type PlainLogModel struct { + viewport viewport.Model + text string +} + +// NewPlainLogModel initializes a new PlainLogModel with the given text. +// It updates a widget with the message `tea.WindowSizeMsg`. +func NewPlainLogModel( + text string, + windowSize tea.WindowSizeMsg, +) (PlainLogModel, tea.Cmd) { + m := PlainLogModel{ + text: text, + viewport: viewport.New(windowSize.Width, windowSize.Height), + } + + m = m.refreshText(windowSize.Width) + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(windowSize) + + return m, cmd +} + +// Init implements team.Model interface. +func (m PlainLogModel) Init() tea.Cmd { return nil } + +// View implements team.Model interface. +func (m PlainLogModel) View() string { + return m.viewport.View() +} + +// Update implements team.Model interface. +func (m PlainLogModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // nolint: gocritic // For future extension. + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height + m = m.refreshText(msg.Width) + } + + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + + return m, cmd +} + +func (m PlainLogModel) refreshText(width int) PlainLogModel { + m.viewport.SetContent(wordwrap.String(m.text, width)) + + return m +} diff --git a/internal/pkg/widgets/plain_test.go b/internal/pkg/widgets/plain_test.go new file mode 100644 index 0000000..93b4f25 --- /dev/null +++ b/internal/pkg/widgets/plain_test.go @@ -0,0 +1,43 @@ +package widgets_test + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + + "github.com/hedhyw/json-log-viewer/internal/pkg/widgets" +) + +const text = "hello world" + +func TestPlainLogModelInit(t *testing.T) { + model, _ := widgets.NewPlainLogModel(text, getFakeTeaWindowSizeMsg()) + + cmd := model.Init() + assert.Nil(t, cmd) +} + +func TestPlainLogModelUpdateTeaWindowSizeMsg(t *testing.T) { + windowSize := getFakeTeaWindowSizeMsg() + model, _ := widgets.NewPlainLogModel(text, windowSize) + + windowSize.Height++ + windowSize.Width++ + + actual, _ := model.Update(windowSize) + if assert.NotNil(t, actual) { + assert.NotEqual(t, actual, model) + } +} + +func TestPlainLogModelView(t *testing.T) { + model, _ := widgets.NewPlainLogModel(text, getFakeTeaWindowSizeMsg()) + + actual := model.View() + assert.Contains(t, actual, text) +} + +func getFakeTeaWindowSizeMsg() tea.WindowSizeMsg { + return tea.WindowSizeMsg{Width: 100, Height: 100} +}