ssm-browse: added structed log view

This commit is contained in:
Leon Mika 2022-03-30 15:07:49 +11:00
parent 9752bb41bc
commit b3d0fbfe29
11 changed files with 546 additions and 0 deletions

51
cmd/slog-view/main.go Normal file
View 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)
}
}

View file

@ -0,0 +1,7 @@
package controllers
import "github.com/lmika/awstools/internal/slog-view/models"
type NewLogFile *models.LogFile
type ViewLogLineFullScreen *models.LogLine

View 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)
}
}

View file

@ -0,0 +1,10 @@
package models
type LogFile struct {
Filename string
Lines []LogLine
}
type LogLine struct {
JSON interface{}
}

View 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
}

View 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
}

View 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
}

View file

@ -0,0 +1,5 @@
package loglines
import "github.com/lmika/awstools/internal/slog-view/models"
type NewLogLineSelected *models.LogLine

View 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
}

View 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)"
}
}

View 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()
}