ssm-browse: added structed log view
This commit is contained in:
parent
9752bb41bc
commit
b3d0fbfe29
51
cmd/slog-view/main.go
Normal file
51
cmd/slog-view/main.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lmika/awstools/internal/common/ui/commandctrl"
|
||||
"github.com/lmika/awstools/internal/common/ui/logging"
|
||||
"github.com/lmika/awstools/internal/slog-view/services/logreader"
|
||||
"github.com/lmika/awstools/internal/slog-view/controllers"
|
||||
"github.com/lmika/awstools/internal/slog-view/ui"
|
||||
"github.com/lmika/gopkgs/cli"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
if flag.NArg() == 0 {
|
||||
cli.Fatal("usage: slog-view LOGFILE")
|
||||
}
|
||||
|
||||
// Pre-determine if layout has dark background. This prevents calls for creating a list to hang.
|
||||
lipgloss.HasDarkBackground()
|
||||
|
||||
closeFn := logging.EnableLogging()
|
||||
defer closeFn()
|
||||
|
||||
service := logreader.NewService()
|
||||
|
||||
ctrl := controllers.NewLogFileController(service, flag.Arg(0))
|
||||
|
||||
cmdController := commandctrl.NewCommandController()
|
||||
//cmdController.AddCommands(&commandctrl.CommandContext{
|
||||
// Commands: map[string]commandctrl.Command{
|
||||
// "cd": func(args []string) tea.Cmd {
|
||||
// return ctrl.ChangePrefix(args[0])
|
||||
// },
|
||||
// },
|
||||
//})
|
||||
|
||||
model := ui.NewModel(ctrl, cmdController)
|
||||
|
||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||
|
||||
if err := p.Start(); err != nil {
|
||||
fmt.Printf("Alas, there's been an error: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
7
internal/slog-view/controllers/events.go
Normal file
7
internal/slog-view/controllers/events.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package controllers
|
||||
|
||||
import "github.com/lmika/awstools/internal/slog-view/models"
|
||||
|
||||
type NewLogFile *models.LogFile
|
||||
|
||||
type ViewLogLineFullScreen *models.LogLine
|
47
internal/slog-view/controllers/logfile.go
Normal file
47
internal/slog-view/controllers/logfile.go
Normal file
|
@ -0,0 +1,47 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lmika/awstools/internal/common/ui/events"
|
||||
"github.com/lmika/awstools/internal/slog-view/models"
|
||||
"github.com/lmika/awstools/internal/slog-view/services/logreader"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type LogFileController struct {
|
||||
logReader *logreader.Service
|
||||
|
||||
// state
|
||||
mutex *sync.Mutex
|
||||
filename string
|
||||
logFile *models.LogFile
|
||||
}
|
||||
|
||||
func NewLogFileController(logReader *logreader.Service, filename string) *LogFileController {
|
||||
return &LogFileController{
|
||||
logReader: logReader,
|
||||
filename: filename,
|
||||
mutex: new(sync.Mutex),
|
||||
}
|
||||
}
|
||||
|
||||
func (lfc *LogFileController) ReadLogFile() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
logFile, err := lfc.logReader.Open(lfc.filename)
|
||||
if err != nil {
|
||||
return events.Error(err)
|
||||
}
|
||||
|
||||
return NewLogFile(logFile)
|
||||
}
|
||||
}
|
||||
|
||||
func (lfc *LogFileController) ViewLogLineFullScreen(line *models.LogLine) tea.Cmd {
|
||||
if line == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return func() tea.Msg {
|
||||
return ViewLogLineFullScreen(line)
|
||||
}
|
||||
}
|
10
internal/slog-view/models/logfile.go
Normal file
10
internal/slog-view/models/logfile.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
package models
|
||||
|
||||
type LogFile struct {
|
||||
Filename string
|
||||
Lines []LogLine
|
||||
}
|
||||
|
||||
type LogLine struct {
|
||||
JSON interface{}
|
||||
}
|
44
internal/slog-view/services/logreader/logreader.go
Normal file
44
internal/slog-view/services/logreader/logreader.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
package logreader
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"github.com/lmika/awstools/internal/slog-view/models"
|
||||
"github.com/pkg/errors"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
func (s *Service) Open(filename string) (*models.LogFile, error) {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "cannot open file: %v", filename)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var lines []models.LogLine
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal([]byte(line), &data); err != nil {
|
||||
log.Println("invalid json line: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
lines = append(lines, models.LogLine{JSON: data})
|
||||
}
|
||||
if scanner.Err() != nil {
|
||||
return nil, errors.Wrapf(err, "unable to scan file: %v", filename)
|
||||
}
|
||||
|
||||
return &models.LogFile{Lines: lines}, nil
|
||||
}
|
65
internal/slog-view/ui/fullviewlinedetails/model.go
Normal file
65
internal/slog-view/ui/fullviewlinedetails/model.go
Normal file
|
@ -0,0 +1,65 @@
|
|||
package fullviewlinedetails
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
|
||||
"github.com/lmika/awstools/internal/slog-view/models"
|
||||
"github.com/lmika/awstools/internal/slog-view/ui/linedetails"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
submodel tea.Model
|
||||
lineDetails *linedetails.Model
|
||||
|
||||
visible bool
|
||||
}
|
||||
|
||||
func NewModel(submodel tea.Model) *Model {
|
||||
return &Model{
|
||||
submodel: submodel,
|
||||
lineDetails: linedetails.New(),
|
||||
}
|
||||
}
|
||||
|
||||
func (*Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.visible = false
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.visible {
|
||||
newModel, cmd := m.lineDetails.Update(msg)
|
||||
m.lineDetails = newModel.(*linedetails.Model)
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.submodel, cmd = m.submodel.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) ViewItem(item *models.LogLine) {
|
||||
m.visible = true
|
||||
m.lineDetails.SetSelectedItem(item)
|
||||
}
|
||||
|
||||
func (m *Model) View() string {
|
||||
if m.visible {
|
||||
return m.lineDetails.View()
|
||||
}
|
||||
return m.submodel.View()
|
||||
}
|
||||
|
||||
func (m *Model) Resize(w, h int) layout.ResizingModel {
|
||||
m.submodel = layout.Resize(m.submodel, w, h)
|
||||
m.lineDetails = layout.Resize(m.lineDetails, w, h).(*linedetails.Model)
|
||||
return m
|
||||
}
|
77
internal/slog-view/ui/linedetails/model.go
Normal file
77
internal/slog-view/ui/linedetails/model.go
Normal file
|
@ -0,0 +1,77 @@
|
|||
package linedetails
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
|
||||
"github.com/lmika/awstools/internal/slog-view/models"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
frameTitle frame.FrameTitle
|
||||
viewport viewport.Model
|
||||
w, h int
|
||||
|
||||
// model state
|
||||
focused bool
|
||||
selectedItem *models.LogLine
|
||||
}
|
||||
|
||||
func New() *Model {
|
||||
viewport := viewport.New(0, 0)
|
||||
viewport.SetContent("")
|
||||
return &Model{
|
||||
frameTitle: frame.NewFrameTitle("Item", false),
|
||||
viewport: viewport,
|
||||
}
|
||||
}
|
||||
|
||||
func (*Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) SetFocused(newFocused bool) {
|
||||
m.focused = newFocused
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if m.focused {
|
||||
newModel, cmd := m.viewport.Update(msg)
|
||||
m.viewport = newModel
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) SetSelectedItem(item *models.LogLine) {
|
||||
m.selectedItem = item
|
||||
|
||||
if m.selectedItem != nil {
|
||||
if formattedJson, err := json.MarshalIndent(item.JSON, "", " "); err == nil {
|
||||
m.viewport.SetContent(string(formattedJson))
|
||||
} else {
|
||||
m.viewport.SetContent("(not json)")
|
||||
}
|
||||
} else {
|
||||
m.viewport.SetContent("(no line)")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) View() string {
|
||||
return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.viewport.View())
|
||||
}
|
||||
|
||||
func (m *Model) Resize(w, h int) layout.ResizingModel {
|
||||
m.w, m.h = w, h
|
||||
m.frameTitle.Resize(w, h)
|
||||
m.viewport.Width = w
|
||||
m.viewport.Height = h - m.frameTitle.HeaderHeight()
|
||||
return m
|
||||
}
|
5
internal/slog-view/ui/loglines/events.go
Normal file
5
internal/slog-view/ui/loglines/events.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package loglines
|
||||
|
||||
import "github.com/lmika/awstools/internal/slog-view/models"
|
||||
|
||||
type NewLogLineSelected *models.LogLine
|
98
internal/slog-view/ui/loglines/model.go
Normal file
98
internal/slog-view/ui/loglines/model.go
Normal file
|
@ -0,0 +1,98 @@
|
|||
package loglines
|
||||
|
||||
import (
|
||||
table "github.com/calyptia/go-bubble-table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/frame"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
|
||||
"github.com/lmika/awstools/internal/slog-view/models"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
frameTitle frame.FrameTitle
|
||||
table table.Model
|
||||
|
||||
logFile *models.LogFile
|
||||
|
||||
w, h int
|
||||
}
|
||||
|
||||
func New() *Model {
|
||||
frameTitle := frame.NewFrameTitle("File: ", true)
|
||||
table := table.New([]string{"level", "error", "message"}, 0, 0)
|
||||
|
||||
return &Model{
|
||||
frameTitle: frameTitle,
|
||||
table: table,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) SetLogFile(newLogFile *models.LogFile) {
|
||||
m.logFile = newLogFile
|
||||
m.frameTitle.SetTitle("File: " + filepath.Base(newLogFile.Filename))
|
||||
|
||||
cols := []string{"level", "error", "message"}
|
||||
|
||||
newTbl := table.New(cols, m.w, m.h-m.frameTitle.HeaderHeight())
|
||||
newRows := make([]table.Row, len(newLogFile.Lines))
|
||||
for i, r := range newLogFile.Lines {
|
||||
newRows[i] = itemTableRow{r}
|
||||
}
|
||||
newTbl.SetRows(newRows)
|
||||
|
||||
m.table = newTbl
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
//var cmd tea.Cmd
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "i", "up":
|
||||
m.table.GoUp()
|
||||
return m, m.emitNewSelectedParameter()
|
||||
case "k", "down":
|
||||
m.table.GoDown()
|
||||
return m, m.emitNewSelectedParameter()
|
||||
}
|
||||
//m.table, cmd = m.table.Update(msg)
|
||||
//return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) SelectedLogLine() *models.LogLine {
|
||||
if row, ok := m.table.SelectedRow().(itemTableRow); ok {
|
||||
return &(row.item)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) emitNewSelectedParameter() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
selectedLogLine := m.SelectedLogLine()
|
||||
if selectedLogLine != nil {
|
||||
return NewLogLineSelected(selectedLogLine)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) View() string {
|
||||
return lipgloss.JoinVertical(lipgloss.Top, m.frameTitle.View(), m.table.View())
|
||||
}
|
||||
|
||||
func (m *Model) Resize(w, h int) layout.ResizingModel {
|
||||
m.w, m.h = w, h
|
||||
m.frameTitle.Resize(w, h)
|
||||
m.table.SetSize(w, h - m.frameTitle.HeaderHeight())
|
||||
return m
|
||||
}
|
||||
|
61
internal/slog-view/ui/loglines/tblmodel.go
Normal file
61
internal/slog-view/ui/loglines/tblmodel.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
package loglines
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
table "github.com/calyptia/go-bubble-table"
|
||||
"github.com/lmika/awstools/internal/slog-view/models"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type itemTableRow struct {
|
||||
item models.LogLine
|
||||
}
|
||||
|
||||
func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) {
|
||||
// TODO: these cols are fixed, they should be dynamic
|
||||
level := mtr.renderFirstLineOfField(mtr.item.JSON, "level")
|
||||
err := mtr.renderFirstLineOfField(mtr.item.JSON, "error")
|
||||
msg := mtr.renderFirstLineOfField(mtr.item.JSON, "message")
|
||||
line := fmt.Sprintf("%s\t%s\t%s", level, err, msg)
|
||||
|
||||
if index == model.Cursor() {
|
||||
fmt.Fprintln(w, model.Styles.SelectedRow.Render(line))
|
||||
} else {
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: this needs to be some form of path expression
|
||||
func (mtr itemTableRow) renderFirstLineOfField(d interface{}, field string) string {
|
||||
switch k := d.(type) {
|
||||
case map[string]interface{}:
|
||||
return mtr.renderFirstLineOfValue(k[field])
|
||||
default:
|
||||
return mtr.renderFirstLineOfValue(k)
|
||||
}
|
||||
}
|
||||
|
||||
func (mtr itemTableRow) renderFirstLineOfValue(v interface{}) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch k := v.(type) {
|
||||
case string:
|
||||
firstLine := strings.SplitN(k, "\n", 2)[0]
|
||||
return firstLine
|
||||
case int:
|
||||
return fmt.Sprint(k)
|
||||
case float64:
|
||||
return fmt.Sprint(k)
|
||||
case bool:
|
||||
return fmt.Sprint(k)
|
||||
case map[string]interface{}:
|
||||
return "{}"
|
||||
case []interface{}:
|
||||
return "[]"
|
||||
default:
|
||||
return "(other)"
|
||||
}
|
||||
}
|
81
internal/slog-view/ui/model.go
Normal file
81
internal/slog-view/ui/model.go
Normal file
|
@ -0,0 +1,81 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/lmika/awstools/internal/common/ui/commandctrl"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/layout"
|
||||
"github.com/lmika/awstools/internal/dynamo-browse/ui/teamodels/statusandprompt"
|
||||
"github.com/lmika/awstools/internal/slog-view/controllers"
|
||||
"github.com/lmika/awstools/internal/slog-view/ui/fullviewlinedetails"
|
||||
"github.com/lmika/awstools/internal/slog-view/ui/linedetails"
|
||||
"github.com/lmika/awstools/internal/slog-view/ui/loglines"
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
controller *controllers.LogFileController
|
||||
cmdController *commandctrl.CommandController
|
||||
|
||||
root tea.Model
|
||||
logLines *loglines.Model
|
||||
lineDetails *linedetails.Model
|
||||
statusAndPrompt *statusandprompt.StatusAndPrompt
|
||||
fullViewLineDetails *fullviewlinedetails.Model
|
||||
}
|
||||
|
||||
func NewModel(controller *controllers.LogFileController, cmdController *commandctrl.CommandController) Model {
|
||||
logLines := loglines.New()
|
||||
lineDetails := linedetails.New()
|
||||
box := layout.NewVBox(layout.LastChildFixedAt(17), logLines, lineDetails)
|
||||
fullViewLineDetails := fullviewlinedetails.NewModel(box)
|
||||
statusAndPrompt := statusandprompt.New(fullViewLineDetails, "")
|
||||
|
||||
root := layout.FullScreen(statusAndPrompt)
|
||||
|
||||
return Model{
|
||||
controller: controller,
|
||||
cmdController: cmdController,
|
||||
root: root,
|
||||
statusAndPrompt: statusAndPrompt,
|
||||
logLines: logLines,
|
||||
lineDetails: lineDetails,
|
||||
fullViewLineDetails: fullViewLineDetails,
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return m.controller.ReadLogFile()
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case controllers.NewLogFile:
|
||||
m.logLines.SetLogFile(msg)
|
||||
case controllers.ViewLogLineFullScreen:
|
||||
m.fullViewLineDetails.ViewItem(msg)
|
||||
case loglines.NewLogLineSelected:
|
||||
m.lineDetails.SetSelectedItem(msg)
|
||||
|
||||
case tea.KeyMsg:
|
||||
if !m.statusAndPrompt.InPrompt() {
|
||||
switch msg.String() {
|
||||
// TEMP
|
||||
case ":":
|
||||
return m, m.cmdController.Prompt()
|
||||
case "w":
|
||||
return m, m.controller.ViewLogLineFullScreen(m.logLines.SelectedLogLine())
|
||||
// END TEMP
|
||||
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newRoot, cmd := m.root.Update(msg)
|
||||
m.root = newRoot
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
return m.root.View()
|
||||
}
|
Loading…
Reference in a new issue