diff --git a/cmd/slog-view/main.go b/cmd/slog-view/main.go new file mode 100644 index 0000000..f577a07 --- /dev/null +++ b/cmd/slog-view/main.go @@ -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) + } +} diff --git a/internal/slog-view/controllers/events.go b/internal/slog-view/controllers/events.go new file mode 100644 index 0000000..81c5305 --- /dev/null +++ b/internal/slog-view/controllers/events.go @@ -0,0 +1,7 @@ +package controllers + +import "github.com/lmika/awstools/internal/slog-view/models" + +type NewLogFile *models.LogFile + +type ViewLogLineFullScreen *models.LogLine \ No newline at end of file diff --git a/internal/slog-view/controllers/logfile.go b/internal/slog-view/controllers/logfile.go new file mode 100644 index 0000000..25e4c2b --- /dev/null +++ b/internal/slog-view/controllers/logfile.go @@ -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) + } +} diff --git a/internal/slog-view/models/logfile.go b/internal/slog-view/models/logfile.go new file mode 100644 index 0000000..700da71 --- /dev/null +++ b/internal/slog-view/models/logfile.go @@ -0,0 +1,10 @@ +package models + +type LogFile struct { + Filename string + Lines []LogLine +} + +type LogLine struct { + JSON interface{} +} diff --git a/internal/slog-view/services/logreader/logreader.go b/internal/slog-view/services/logreader/logreader.go new file mode 100644 index 0000000..f33a40d --- /dev/null +++ b/internal/slog-view/services/logreader/logreader.go @@ -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 +} diff --git a/internal/slog-view/ui/fullviewlinedetails/model.go b/internal/slog-view/ui/fullviewlinedetails/model.go new file mode 100644 index 0000000..6b0aca5 --- /dev/null +++ b/internal/slog-view/ui/fullviewlinedetails/model.go @@ -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 +} diff --git a/internal/slog-view/ui/linedetails/model.go b/internal/slog-view/ui/linedetails/model.go new file mode 100644 index 0000000..25e3c22 --- /dev/null +++ b/internal/slog-view/ui/linedetails/model.go @@ -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 +} diff --git a/internal/slog-view/ui/loglines/events.go b/internal/slog-view/ui/loglines/events.go new file mode 100644 index 0000000..eb1899c --- /dev/null +++ b/internal/slog-view/ui/loglines/events.go @@ -0,0 +1,5 @@ +package loglines + +import "github.com/lmika/awstools/internal/slog-view/models" + +type NewLogLineSelected *models.LogLine diff --git a/internal/slog-view/ui/loglines/model.go b/internal/slog-view/ui/loglines/model.go new file mode 100644 index 0000000..022d7dc --- /dev/null +++ b/internal/slog-view/ui/loglines/model.go @@ -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 +} + diff --git a/internal/slog-view/ui/loglines/tblmodel.go b/internal/slog-view/ui/loglines/tblmodel.go new file mode 100644 index 0000000..6adc5d8 --- /dev/null +++ b/internal/slog-view/ui/loglines/tblmodel.go @@ -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)" + } +} \ No newline at end of file diff --git a/internal/slog-view/ui/model.go b/internal/slog-view/ui/model.go new file mode 100644 index 0000000..41f5aea --- /dev/null +++ b/internal/slog-view/ui/model.go @@ -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() +}