From c37f72b67fb67a612a47a139b3956603fee5eae0 Mon Sep 17 00:00:00 2001 From: Simon Paulger Date: Thu, 9 Oct 2025 21:24:46 +0100 Subject: [PATCH] Build history command using bubbletea Build history command using bubbletea --- cmd/solo/subcommand/root.go | 1 + cmd/solo/subcommand/wflog.go | 36 ++ go.mod | 16 + go.sum | 36 ++ internal/pkg/architecture_test.go | 3 + .../host/app/audit/state_directory_auditor.go | 14 +- .../bubbletea/components/heading/component.go | 57 +++ .../app/bubbletea/components/heading/types.go | 3 + .../bubbletea/components/table/component.go | 372 ++++++++++++++++++ .../bubbletea/components/table/messages.go | 11 + .../app/bubbletea/components/table/types.go | 49 +++ .../host/app/bubbletea/layout/dimension.go | 23 ++ .../pkg/host/app/bubbletea/layout/manager.go | 154 ++++++++ .../host/app/bubbletea/layout/manager_test.go | 125 ++++++ .../pkg/host/app/bubbletea/layout/spec.go | 49 +++ .../bubbletea/messages/component_size_msg.go | 6 + .../app/bubbletea/messages/execution_event.go | 9 + .../messages/workflow_event_selected.go | 7 + .../execution_event_history/messages.go | 9 + .../models/execution_event_history/model.go | 165 ++++++++ .../models/execution_event_overview/model.go | 76 ++++ .../models/workflow_event_output/model.go | 51 +++ .../models/workflow_event_tree/model.go | 238 +++++++++++ .../models/workflow_event_tree/types.go | 16 + .../app/bubbletea/views/workflow_log/view.go | 112 ++++++ .../pkg/host/app/project_control_factory.go | 24 +- .../pkg/host/domain/container_step_map.go | 11 + .../domain/container_step_map_repository.go | 5 + internal/pkg/host/domain/entity_repository.go | 6 + internal/pkg/host/domain/workflow_log_meta.go | 11 - .../domain/workflow_log_meta_repository.go | 5 - .../infra/repository/json_file_repository.go | 39 ++ .../infra/repository/json_file_repository.go | 12 + 33 files changed, 1716 insertions(+), 35 deletions(-) create mode 100644 cmd/solo/subcommand/wflog.go create mode 100644 internal/pkg/host/app/bubbletea/components/heading/component.go create mode 100644 internal/pkg/host/app/bubbletea/components/heading/types.go create mode 100644 internal/pkg/host/app/bubbletea/components/table/component.go create mode 100644 internal/pkg/host/app/bubbletea/components/table/messages.go create mode 100644 internal/pkg/host/app/bubbletea/components/table/types.go create mode 100644 internal/pkg/host/app/bubbletea/layout/dimension.go create mode 100644 internal/pkg/host/app/bubbletea/layout/manager.go create mode 100644 internal/pkg/host/app/bubbletea/layout/manager_test.go create mode 100644 internal/pkg/host/app/bubbletea/layout/spec.go create mode 100644 internal/pkg/host/app/bubbletea/messages/component_size_msg.go create mode 100644 internal/pkg/host/app/bubbletea/messages/execution_event.go create mode 100644 internal/pkg/host/app/bubbletea/messages/workflow_event_selected.go create mode 100644 internal/pkg/host/app/bubbletea/models/execution_event_history/messages.go create mode 100644 internal/pkg/host/app/bubbletea/models/execution_event_history/model.go create mode 100644 internal/pkg/host/app/bubbletea/models/execution_event_overview/model.go create mode 100644 internal/pkg/host/app/bubbletea/models/workflow_event_output/model.go create mode 100644 internal/pkg/host/app/bubbletea/models/workflow_event_tree/model.go create mode 100644 internal/pkg/host/app/bubbletea/models/workflow_event_tree/types.go create mode 100644 internal/pkg/host/app/bubbletea/views/workflow_log/view.go create mode 100644 internal/pkg/host/domain/container_step_map.go create mode 100644 internal/pkg/host/domain/container_step_map_repository.go delete mode 100644 internal/pkg/host/domain/workflow_log_meta.go delete mode 100644 internal/pkg/host/domain/workflow_log_meta_repository.go diff --git a/cmd/solo/subcommand/root.go b/cmd/solo/subcommand/root.go index 391e414f..da4201e1 100644 --- a/cmd/solo/subcommand/root.go +++ b/cmd/solo/subcommand/root.go @@ -46,6 +46,7 @@ func Execute() int { rootCmd.AddCommand(NewCleanSubCommand(soloCtx)) // Tooling + rootCmd.AddCommand(NewWorkflowLogSubCommand(soloCtx)) rootCmd.AddCommand(NewShCommand(soloCtx)) rootCmd.AddCommand(NewLogsCommand(soloCtx)) rootCmd.AddCommand(NewToolCommands(soloCtx)...) diff --git a/cmd/solo/subcommand/wflog.go b/cmd/solo/subcommand/wflog.go new file mode 100644 index 00000000..c2214f69 --- /dev/null +++ b/cmd/solo/subcommand/wflog.go @@ -0,0 +1,36 @@ +package subcommand + +import ( + "github.com/spf13/cobra" + + tea "charm.land/bubbletea/v2" + + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/views/workflow_log" + + "github.com/spaulg/solo/internal/pkg/host/app/context" +) + +func NewWorkflowLogSubCommand(soloCtx *context.CliContext) *cobra.Command { + buildLogCmd := &cobra.Command{ + Use: "wflogs", + GroupID: "tooling", + Short: "Workflow logs", + Long: "Workflow logs", + Aliases: []string{"wflog"}, + RunE: soloCtx.ProtectWithLock(func(cmd *cobra.Command, args []string) error { + model, err := workflow_log.NewView(soloCtx) + if err != nil { + return err + } + + p := tea.NewProgram(model) + if _, err := p.Run(); err != nil { + return err + } + + return nil + }), + } + + return buildLogCmd +} diff --git a/go.mod b/go.mod index f2fdc9d1..4aceb095 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,9 @@ module github.com/spaulg/solo go 1.25.0 require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.7 + charm.land/lipgloss/v2 v2.0.4 github.com/arch-go/arch-go/v2 v2.1.2 github.com/compose-spec/compose-go/v2 v2.4.1 github.com/gofrs/flock v0.12.1 @@ -19,6 +22,14 @@ require ( ) require ( + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.5.0 // indirect @@ -27,12 +38,16 @@ require ( github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect @@ -43,6 +58,7 @@ require ( github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f // indirect diff --git a/go.sum b/go.sum index d38129f2..703408d5 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,35 @@ +charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= +charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= +charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= +charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= +charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q= +charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik= github.com/agiledragon/gomonkey/v2 v2.14.0 h1:FASzes6sjtD0hRo5lu0g796qKL03bOHCgcIA/4am9QM= github.com/agiledragon/gomonkey/v2 v2.14.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= github.com/arch-go/arch-go/v2 v2.1.2 h1:kYf2VIVjtbKLGTs0/lr+2FOqiXFFKAiGc4UhoIS/l0M= github.com/arch-go/arch-go/v2 v2.1.2/go.mod h1:8W9kj7Pgv0iPioau6yoaiC3HLvzqyq5LydXam9Z1Fpc= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= +github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/compose-spec/compose-go/v2 v2.4.1 h1:tEg6Qn/9LZnKg42fZlFmxN4lxSqnCvsiG5TXnxzvI4c= github.com/compose-spec/compose-go/v2 v2.4.1/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -43,13 +69,19 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= @@ -60,6 +92,8 @@ github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xl github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -94,6 +128,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= diff --git a/internal/pkg/architecture_test.go b/internal/pkg/architecture_test.go index 20fb9203..5f652f45 100644 --- a/internal/pkg/architecture_test.go +++ b/internal/pkg/architecture_test.go @@ -82,6 +82,9 @@ func (t *ArchitectureTestSuite) TestLayerDependencies() { "github.com/gofrs/flock.**", "github.com/oklog/ulid/v2.**", "github.com/spf13/cobra.**", + "charm.land/bubbles/v2/key.**", + "charm.land/bubbletea/v2.**", + "charm.land/lipgloss/v2.**", }, }, }, diff --git a/internal/pkg/host/app/audit/state_directory_auditor.go b/internal/pkg/host/app/audit/state_directory_auditor.go index 30b066ec..8ae25487 100644 --- a/internal/pkg/host/app/audit/state_directory_auditor.go +++ b/internal/pkg/host/app/audit/state_directory_auditor.go @@ -24,7 +24,7 @@ type StateDirectoryAuditor struct { mu sync.Mutex outputDirectory string executionEventRepository domain.ExecutionEventRepository - workflowLogMetaRepository domain.WorkflowLogMetaRepository + containerStepMapRepository domain.ContainerStepMapRepository workflowStepLogMetaRepository domain.WorkflowStepLogMetaRepository logWriter domain.LogWriter } @@ -35,7 +35,7 @@ func NewAuditor( config *domain.Config, project domain.Project, executionEventRepository domain.ExecutionEventRepository, - workflowLogMetaRepository domain.WorkflowLogMetaRepository, + containerStepMapRepository domain.ContainerStepMapRepository, workflowStepLogMetaRepository domain.WorkflowStepLogMetaRepository, logWriter domain.LogWriter, ) *StateDirectoryAuditor { @@ -52,7 +52,7 @@ func NewAuditor( project: project, outputDirectory: outputDirectory, executionEventRepository: executionEventRepository, - workflowLogMetaRepository: workflowLogMetaRepository, + containerStepMapRepository: containerStepMapRepository, workflowStepLogMetaRepository: workflowStepLogMetaRepository, logWriter: logWriter, } @@ -200,8 +200,8 @@ func (t *StateDirectoryAuditor) writeStepResult(e *wf.StepCompleteEvent) { )) } - workflowMetaPath := path.Join(outputDirectory, e.WorkflowName.String()+".meta.json") - workflowMeta, err := t.workflowLogMetaRepository.Load(workflowMetaPath) + workflowMetaPath := path.Join(outputDirectory, "container_step_map.meta.json") + workflowMeta, err := t.containerStepMapRepository.Load(workflowMetaPath) if err != nil { t.logger.Error(fmt.Sprintf( "Failed to load workflow meta file: failed to load meta file: %s: %v", @@ -213,12 +213,12 @@ func (t *StateDirectoryAuditor) writeStepResult(e *wf.StepCompleteEvent) { } if workflowMeta == nil { - workflowMeta = domain.NewWorkflowLogMeta() + workflowMeta = domain.NewWorkflowContainerStepMap() } workflowMeta.AppendStep(e.ContainerName, e.StepID) - if err = t.workflowLogMetaRepository.Save(workflowMetaPath, workflowMeta); err != nil { + if err = t.containerStepMapRepository.Save(workflowMetaPath, workflowMeta); err != nil { t.logger.Error(fmt.Sprintf( "Failed to write workflow meta file: failed to save meta file: %s: %v", workflowMetaPath, diff --git a/internal/pkg/host/app/bubbletea/components/heading/component.go b/internal/pkg/host/app/bubbletea/components/heading/component.go new file mode 100644 index 00000000..626c564d --- /dev/null +++ b/internal/pkg/host/app/bubbletea/components/heading/component.go @@ -0,0 +1,57 @@ +package heading + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/messages" +) + +type Component struct { + heading string + style lipgloss.Style + + width int + height int +} + +func DefaultStyle() lipgloss.Style { + return lipgloss.NewStyle().Align(lipgloss.Center).Background(lipgloss.Color("212")) +} + +func WithStyle(style lipgloss.Style) Option { + return func(component *Component) { + component.style = style + } +} + +func NewComponent(heading string, opts ...Option) Component { + component := Component{ + heading: heading, + style: DefaultStyle(), + } + + for _, opt := range opts { + opt(&component) + } + + return component +} + +func (t Component) Init() tea.Cmd { + return nil +} + +func (t Component) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + + switch m := msg.(type) { + case messages.ComponentSizeMsg: + t.width, t.height = m.Width, m.Height + } + + return t, nil +} + +func (t Component) View() tea.View { + return tea.NewView(t.style.Width(t.width).Height(t.height).Render(t.heading)) +} diff --git a/internal/pkg/host/app/bubbletea/components/heading/types.go b/internal/pkg/host/app/bubbletea/components/heading/types.go new file mode 100644 index 00000000..7af64a21 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/components/heading/types.go @@ -0,0 +1,3 @@ +package heading + +type Option func(component *Component) diff --git a/internal/pkg/host/app/bubbletea/components/table/component.go b/internal/pkg/host/app/bubbletea/components/table/component.go new file mode 100644 index 00000000..0e8da044 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/components/table/component.go @@ -0,0 +1,372 @@ +package table + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type Component struct { + width int + height int + + rowChangeCallback func(selectedRow RowID) func() tea.Msg + rowSelectCallback func(selectedRow RowID) func() tea.Msg + columnOrder []ColumnID + selectedRow RowID + selectedIndex int + + offset int + columns Columns + rows Rows + + keyMap KeyMap + styles Styles + + dirty bool + view string +} + +func DefaultStyles() Styles { + return Styles{ + Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Inline(true), + Row: lipgloss.NewStyle().Inline(true), + Header: lipgloss.NewStyle().Bold(true).Padding(0, 1).Inline(true), + Cell: lipgloss.NewStyle().Padding(0, 1), + Box: lipgloss.NewStyle(), + } +} + +func WithRowChangeCallback(rowChangeCallback func(selectedRow RowID) func() tea.Msg) Option { + return func(t *Component) { + t.rowChangeCallback = rowChangeCallback + } +} + +func WithRowSelectCallback(rowSelectCallback func(selectedRow RowID) func() tea.Msg) Option { + return func(t *Component) { + t.rowSelectCallback = rowSelectCallback + } +} + +func DefaultKeyMap() KeyMap { + const spacebar = " " + + return KeyMap{ + LineUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + LineDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + PageUp: key.NewBinding( + key.WithKeys("b", "pgup"), + key.WithHelp("b/pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("f", "pgdown", spacebar), + key.WithHelp("f/pgdn", "page down"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select row"), + ), + } +} + +func WithColumnOrder(columnOrder []ColumnID) Option { + return func(t *Component) { + t.columnOrder = columnOrder + } +} + +func WithColumns(columns Columns) Option { + return func(t *Component) { + t.columns = columns + } +} + +func WithRows(rows Rows) Option { + return func(t *Component) { + t.rows = rows + } +} + +func WithDimensions(w, h int) Option { + return func(t *Component) { + t.width = w + t.height = h + + t.styles.Box = t.styles.Box. + Width(t.width). + MaxWidth(t.width). + Height(t.height). + MaxHeight(t.height) + + t.styles.Selected = t.styles.Selected. + MaxWidth(t.width) + + t.styles.Row = t.styles.Row. + MaxWidth(t.width) + } +} + +func WithKeyMap(keymap KeyMap) Option { + return func(t *Component) { + t.keyMap = keymap + } +} + +func NewComponent(opts ...Option) Component { + component := Component{ + dirty: true, + styles: DefaultStyles(), + keyMap: DefaultKeyMap(), + } + + for _, opt := range opts { + opt(&component) + } + + return component +} + +func (t Component) Init() tea.Cmd { + return nil +} + +func (t Component) Update(msg tea.Msg) (Component, tea.Cmd) { + var cmds []tea.Cmd + + switch m := msg.(type) { + case tea.KeyMsg: + rowChanged := false + + switch { + case key.Matches(m, t.keyMap.LineUp): + rowChanged = t.selectPrevRow() + case key.Matches(m, t.keyMap.LineDown): + rowChanged = t.selectNextRow() + case key.Matches(m, t.keyMap.PageUp): + rowChanged = t.selectPrevPage() + case key.Matches(m, t.keyMap.PageDown): + rowChanged = t.selectNextPage() + case key.Matches(m, t.keyMap.Enter): + if t.rowSelectCallback != nil { + cmds = append(cmds, t.rowSelectCallback(t.selectedRow)) + } + } + + if rowChanged && t.rowChangeCallback != nil { + cmds = append(cmds, t.rowChangeCallback(t.selectedRow)) + } + + case SetComponentSizeMsg: + for columnID, width := range m.ColumnWidths { + t.setColumnWidth(columnID, width) + } + + t.setDimensions(m.Width, m.Height) + + case SetRowsMsg: + t.setRows(m.Rows) + + if t.rowChangeCallback != nil { + cmds = append(cmds, t.rowChangeCallback(t.selectedRow)) + } + } + + t.refreshView() + + return t, tea.Batch(cmds...) +} + +func (t Component) View() tea.View { + return tea.NewView(t.view) +} + +func (t *Component) setColumnWidth(columnID ColumnID, w int) *Component { + if column, found := t.columns[columnID]; found { + t.dirty = true + column.Width = w + } + + return t +} + +func (t *Component) setRows(rows Rows) *Component { + t.dirty = true + t.rows = rows + t.selectedRow = rows[0].ID + t.selectedIndex = 0 + + return t +} + +func (t *Component) setDimensions(w, h int) *Component { + t.dirty = true + t.width = w + t.height = h + + t.styles.Box = t.styles.Box. + Width(t.width). + MaxWidth(t.width). + Height(t.height). + MaxHeight(t.height) + + t.styles.Selected = t.styles.Selected. + MaxWidth(t.width) + + t.styles.Row = t.styles.Row. + MaxWidth(t.width) + + return t +} + +func (t *Component) selectPrevRow() bool { + if t.selectedIndex == 0 { + return false + } + + // If the selected index moves before the offset, + // move the offset back as well + if t.selectedIndex <= t.offset { + t.offset-- + } + + t.selectedIndex-- + t.selectedRow = t.rows[t.selectedIndex].ID + t.dirty = true + + return true +} + +func (t *Component) selectNextRow() bool { + if len(t.rows) <= t.selectedIndex+1 { + return false + } + + // If selected index moves beyond the offset+height + // move the offset forward as well + if t.selectedIndex >= t.offset+t.height-2 { + t.offset++ + } + + t.selectedIndex++ + t.selectedRow = t.rows[t.selectedIndex].ID + t.dirty = true + + return true +} + +func (t *Component) selectPrevPage() bool { + // If on the first row, ignore + if t.selectedIndex == 0 { + return false + } + + if len(t.rows) <= t.height { + // Less than one page, select the first row + t.selectedIndex = 0 + } else { + // More than one page left, move forward one page + t.offset -= t.height - 1 + t.selectedIndex -= t.height - 1 + + // If by moving backward one page, we end up on the first page + // adjust the offset + if t.offset < 0 { + t.offset = 0 + } + } + + t.selectedRow = t.rows[t.selectedIndex].ID + t.dirty = true + + return true +} + +func (t *Component) selectNextPage() bool { + // If on the last row, ignore + if len(t.rows) == t.selectedIndex+1 { + return false + } + + if t.offset+t.height-1 >= len(t.rows) { + // Less than one page, select the last row, or + // on the last page, select the last row + t.selectedIndex = len(t.rows) - 1 + } else { + // More than one page left, move forward one page + t.offset += t.height - 1 + t.selectedIndex += t.height - 1 + + // If by moving forward one page, we end up on the last page + // adjust the offset + if t.offset+t.height >= len(t.rows) { + t.offset = len(t.rows) - t.height + 1 + t.selectedIndex = len(t.rows) - 1 + } + } + + t.selectedRow = t.rows[t.selectedIndex].ID + t.dirty = true + + return true +} + +func (t *Component) refreshView() { + if t.dirty { + var view string + rowCount := 1 + + // Render columns + renderedColumnList := make([]string, len(t.columns)) + for _, columnID := range t.columnOrder { + if column, found := t.columns[columnID]; found { + renderedColumn := t.styles.Header.Width(column.Width).Render(column.Title) + renderedColumnList = append(renderedColumnList, renderedColumn) + } + } + + renderedColumns := lipgloss.JoinHorizontal(lipgloss.Top, renderedColumnList...) + "\n" + renderedColumns = t.styles.Row.Render(renderedColumns) + view += renderedColumns + "\n" + + // Render rows + if len(t.rows) > 0 { + for _, row := range t.rows[t.offset:] { + if rowCount >= t.height { + break + } + + renderedCells := make([]string, len(t.columns)) + + for _, cell := range row.Cells { + if column, found := t.columns[cell.ColumnID]; found { + style := t.styles.Cell + + renderedCell := style.Width(column.Width).Render(cell.Value) + renderedCells = append(renderedCells, renderedCell) + } + } + + renderedRow := lipgloss.JoinHorizontal(lipgloss.Top, renderedCells...) + + if row.ID == t.selectedRow { + renderedRow = t.styles.Selected.Render(renderedRow) + } else { + renderedRow = t.styles.Row.Render(renderedRow) + } + + view += renderedRow + "\n" + rowCount++ + } + } + + t.view = t.styles.Box.Render(view) + t.dirty = false + } +} diff --git a/internal/pkg/host/app/bubbletea/components/table/messages.go b/internal/pkg/host/app/bubbletea/components/table/messages.go new file mode 100644 index 00000000..8d0db8d6 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/components/table/messages.go @@ -0,0 +1,11 @@ +package table + +type SetRowsMsg struct { + Rows Rows +} + +type SetComponentSizeMsg struct { + Width int + Height int + ColumnWidths map[ColumnID]int +} diff --git a/internal/pkg/host/app/bubbletea/components/table/types.go b/internal/pkg/host/app/bubbletea/components/table/types.go new file mode 100644 index 00000000..8d48f16c --- /dev/null +++ b/internal/pkg/host/app/bubbletea/components/table/types.go @@ -0,0 +1,49 @@ +package table + +import ( + "charm.land/bubbles/v2/key" + "charm.land/lipgloss/v2" +) + +type RowID string +type ColumnID string + +type Column struct { + Title string + Width int +} + +type Cell struct { + ColumnID ColumnID + Value string +} + +type Row struct { + ID RowID + Cells []Cell +} + +type Columns map[ColumnID]*Column +type Rows []Row + +type Styles struct { + Box lipgloss.Style + Row lipgloss.Style + Header lipgloss.Style + Cell lipgloss.Style + Selected lipgloss.Style +} + +type Option func(component *Component) + +type KeyMap struct { + LineUp key.Binding + LineDown key.Binding + PageUp key.Binding + PageDown key.Binding + Enter key.Binding + HalfPageUp key.Binding + HalfPageDown key.Binding + GotoTop key.Binding + GotoBottom key.Binding +} diff --git a/internal/pkg/host/app/bubbletea/layout/dimension.go b/internal/pkg/host/app/bubbletea/layout/dimension.go new file mode 100644 index 00000000..d68def50 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/layout/dimension.go @@ -0,0 +1,23 @@ +package layout + +import ( + "charm.land/lipgloss/v2" +) + +type Dimension struct { + Style lipgloss.Style + Width int + Height int +} + +func (t Dimension) ContentBoxWidth() int { + return t.Width - + t.Style.GetBorderLeftSize() - t.Style.GetBorderRightSize() - + t.Style.GetPaddingLeft() - t.Style.GetPaddingRight() +} + +func (t Dimension) ContentBoxHeight() int { + return t.Height - + t.Style.GetBorderTopSize() - t.Style.GetBorderBottomSize() - + t.Style.GetPaddingTop() - t.Style.GetPaddingBottom() +} diff --git a/internal/pkg/host/app/bubbletea/layout/manager.go b/internal/pkg/host/app/bubbletea/layout/manager.go new file mode 100644 index 00000000..b890d182 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/layout/manager.go @@ -0,0 +1,154 @@ +package layout + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/messages" +) + +type Manager struct { + layoutDirection Direction + layoutSpecs []Spec +} + +type Direction int + +type Type int + +const ( + HorizontalLayoutDirection Direction = 1 + VerticalLayoutDirection Direction = 2 + + PercentageLayoutType Type = 1 + FixedLayoutType Type = 2 + FillLayoutType Type = 3 +) + +func NewLayoutManager(layoutDirection Direction, layoutSpecs []Spec) *Manager { + var fillSpec *Spec + for i, spec := range layoutSpecs { + if spec.Type != FillLayoutType { + continue + } + + if fillSpec != nil { + // If multiple fill specs are found, silently override + // extra ones to a percentage layout with 0 size + // This avoids having to raise and return an error throughout the + // model constructor hierarchy + layoutSpecs[i].Type = PercentageLayoutType + layoutSpecs[i].Size = 0 + } + + fillSpec = &spec + } + + if fillSpec == nil { + layoutSpecs = append(layoutSpecs, NewFillLayoutSpec(lipgloss.NewStyle())) + } + + return &Manager{ + layoutDirection: layoutDirection, + layoutSpecs: layoutSpecs, + } +} + +func (t *Manager) Render(sourceStrings ...string) string { + renderedStrings := make([]string, len(sourceStrings)) + + for i, str := range sourceStrings { + renderedStrings[i] = t.layoutSpecs[i].Style.Render(str) + } + + if t.layoutDirection == HorizontalLayoutDirection { + return lipgloss.JoinHorizontal(lipgloss.Left, renderedStrings...) + } else { + return lipgloss.JoinVertical(lipgloss.Top, renderedStrings...) + } +} + +func (t *Manager) CalculateDimensions(w int, h int) []*Dimension { + var axisDimensions []*int + dimensions := make([]*Dimension, 0) + + // Calculate axis sizes + if t.layoutDirection == HorizontalLayoutDirection { + axisDimensions = t.calculateAxisDimensions(w) + } else { + axisDimensions = t.calculateAxisDimensions(h) + } + + // Reformat the axis sizes in to an + // array of dimension structures + for i, axisDimension := range axisDimensions { + var dimension *Dimension + + if t.layoutDirection == HorizontalLayoutDirection { + dimension = &Dimension{ + Style: t.layoutSpecs[i].Style, + Width: *axisDimension, + Height: h, + } + } else { + dimension = &Dimension{ + Style: t.layoutSpecs[i].Style, + Width: w, + Height: *axisDimension, + } + } + + dimensions = append(dimensions, dimension) + } + + return dimensions +} + +func (t *Manager) ResizeComponents(w int, h int, components ...*tea.Model) []tea.Cmd { + var cmd tea.Cmd + var cmds []tea.Cmd + + dimensions := t.CalculateDimensions(w, h) + + for id, model := range components { + *model, cmd = (*model).Update(messages.ComponentSizeMsg{ + Width: dimensions[id].ContentBoxWidth(), + Height: dimensions[id].ContentBoxHeight(), + }) + + cmds = append(cmds, cmd) + } + + return cmds +} + +func (t *Manager) calculateAxisDimensions(axis int) []*int { + runningSize := 0 + dimensions := make([]*int, 0) + + var fillDimension *int + var fillLayoutSpec *Spec + + for _, layoutSpec := range t.layoutSpecs { + calculatedSize := 0 + + if layoutSpec.Type != FillLayoutType { + calculatedSize = layoutSpec.CalculateBorderBoxSize(axis, runningSize) + runningSize += calculatedSize + } + + dimension := &calculatedSize + dimensions = append(dimensions, dimension) + + if layoutSpec.Type == FillLayoutType { + fillDimension = dimension + fillLayoutSpec = &layoutSpec + } + } + + if fillLayoutSpec != nil && fillDimension != nil { + *fillDimension = fillLayoutSpec.CalculateBorderBoxSize(axis, runningSize) + } + + return dimensions +} diff --git a/internal/pkg/host/app/bubbletea/layout/manager_test.go b/internal/pkg/host/app/bubbletea/layout/manager_test.go new file mode 100644 index 00000000..f222f0e1 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/layout/manager_test.go @@ -0,0 +1,125 @@ +package layout + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +func TestLayoutManagerTestSuite(t *testing.T) { + suite.Run(t, new(LayoutManagerTestSuite)) +} + +type LayoutManagerTestSuite struct { + suite.Suite +} + +func (t *LayoutManagerTestSuite) TestNoFillLayoutDefaults() { + layoutManager := NewLayoutManager(HorizontalLayoutDirection, []Spec{ + { + Type: PercentageLayoutType, + Size: 25, + }, + }) + + dimensions := layoutManager.CalculateDimensions(251, 71) + t.Assert().Len(dimensions, 2) + + t.Equal(63, dimensions[0].Width) + t.Equal(71, dimensions[0].Height) + + t.Equal(188, dimensions[1].Width) + t.Equal(71, dimensions[1].Height) +} + +func (t *LayoutManagerTestSuite) TestMultipleFillReplaces() { + layoutManager := NewLayoutManager(HorizontalLayoutDirection, []Spec{ + { + Type: FillLayoutType, + }, + { + Type: FillLayoutType, + }, + }) + + dimensions := layoutManager.CalculateDimensions(251, 71) + t.Assert().Len(dimensions, 2) + + t.Equal(251, dimensions[0].Width) + t.Equal(71, dimensions[0].Height) + + t.Equal(0, dimensions[1].Width) + t.Equal(71, dimensions[1].Height) +} + +func (t *LayoutManagerTestSuite) TestHorizontalLayout() { + layoutManager := NewLayoutManager(HorizontalLayoutDirection, []Spec{ + { + Type: PercentageLayoutType, + Size: 25, + }, + { + Type: PercentageLayoutType, + Size: 25, + }, + { + Type: FillLayoutType, + }, + }) + + dimensions := layoutManager.CalculateDimensions(251, 71) + t.Assert().Len(dimensions, 3) + + t.Equal(63, dimensions[0].Width) + t.Equal(71, dimensions[0].Height) + + t.Equal(63, dimensions[1].Width) + t.Equal(71, dimensions[1].Height) + + t.Equal(125, dimensions[2].Width) + t.Equal(71, dimensions[2].Height) +} + +func (t *LayoutManagerTestSuite) TestVerticalLayout() { + layoutManager := NewLayoutManager(VerticalLayoutDirection, []Spec{ + { + Type: PercentageLayoutType, + Size: 25, + }, + { + Type: PercentageLayoutType, + Size: 25, + }, + { + Type: FillLayoutType, + }, + }) + + dimensions := layoutManager.CalculateDimensions(251, 71) + t.Assert().Len(dimensions, 3) + + t.Equal(251, dimensions[0].Width) + t.Equal(18, dimensions[0].Height) + + t.Equal(251, dimensions[1].Width) + t.Equal(18, dimensions[1].Height) + + t.Equal(251, dimensions[2].Width) + t.Equal(35, dimensions[2].Height) +} + +// Percentage only +// Percentage with min & max +// Percentage with style +// Percentage with min & max, with style +// Percentage + fill + +// Fixed only +// Fixed with style +// Fixed + fill + +// Percentage + fixed + +// Unhappy paths +// no fill type - 1 fill type is always required +// more than one fill type - there can be only one diff --git a/internal/pkg/host/app/bubbletea/layout/spec.go b/internal/pkg/host/app/bubbletea/layout/spec.go new file mode 100644 index 00000000..2a0bc8fa --- /dev/null +++ b/internal/pkg/host/app/bubbletea/layout/spec.go @@ -0,0 +1,49 @@ +package layout + +import ( + "math" + + "charm.land/lipgloss/v2" +) + +type Spec struct { + Type Type + Style lipgloss.Style + Size int +} + +func NewPercentageLayoutSpec(size int, style lipgloss.Style) Spec { + return Spec{ + Type: PercentageLayoutType, + Size: size, + Style: style, + } +} + +func NewFixedLayoutSpec(size int, style lipgloss.Style) Spec { + return Spec{ + Type: FixedLayoutType, + Size: size, + Style: style, + } +} + +func NewFillLayoutSpec(style lipgloss.Style) Spec { + return Spec{ + Type: FillLayoutType, + Style: style, + } +} + +func (t Spec) CalculateBorderBoxSize(totalSize int, usedSize int) int { + switch t.Type { + case PercentageLayoutType: + return int(math.Round(float64(totalSize) * (float64(t.Size) / 100))) + case FixedLayoutType: + return t.Size + case FillLayoutType: + return totalSize - usedSize + default: + return 0 + } +} diff --git a/internal/pkg/host/app/bubbletea/messages/component_size_msg.go b/internal/pkg/host/app/bubbletea/messages/component_size_msg.go new file mode 100644 index 00000000..6596dc22 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/messages/component_size_msg.go @@ -0,0 +1,6 @@ +package messages + +type ComponentSizeMsg struct { + Width int + Height int +} diff --git a/internal/pkg/host/app/bubbletea/messages/execution_event.go b/internal/pkg/host/app/bubbletea/messages/execution_event.go new file mode 100644 index 00000000..a09cb3dc --- /dev/null +++ b/internal/pkg/host/app/bubbletea/messages/execution_event.go @@ -0,0 +1,9 @@ +package messages + +type ExecutionEventHighlighted struct { + ExecutionEvent string +} + +type ExecutionEventSelected struct { + ExecutionEvent string +} diff --git a/internal/pkg/host/app/bubbletea/messages/workflow_event_selected.go b/internal/pkg/host/app/bubbletea/messages/workflow_event_selected.go new file mode 100644 index 00000000..0698c4f6 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/messages/workflow_event_selected.go @@ -0,0 +1,7 @@ +package messages + +type WorkflowEventSelected struct { + ExecutionEventName string + ContainerName string + WorkflowName string +} diff --git a/internal/pkg/host/app/bubbletea/models/execution_event_history/messages.go b/internal/pkg/host/app/bubbletea/models/execution_event_history/messages.go new file mode 100644 index 00000000..d077ffdc --- /dev/null +++ b/internal/pkg/host/app/bubbletea/models/execution_event_history/messages.go @@ -0,0 +1,9 @@ +package execution_event_history + +import ( + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/components/table" +) + +type ExecutionEventHistoryDataLoaded struct { + rows table.Rows +} diff --git a/internal/pkg/host/app/bubbletea/models/execution_event_history/model.go b/internal/pkg/host/app/bubbletea/models/execution_event_history/model.go new file mode 100644 index 00000000..9dfeb32b --- /dev/null +++ b/internal/pkg/host/app/bubbletea/models/execution_event_history/model.go @@ -0,0 +1,165 @@ +package execution_event_history + +import ( + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/spaulg/solo/internal/pkg/host/app/context" + "github.com/spaulg/solo/internal/pkg/host/domain" + + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/components/table" + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/messages" +) + +const minEventColumnWidth = 15 +const minCommandColumnWidth = 40 +const minStatusColumnWidth = 20 +const minDateColumnWidth = 30 +const minErrorColumnWidth = 30 +const defaultWidth = 0 +const defaultHeight = 0 + +type Option func(component *Model) + +type Model struct { + width int + height int + active bool + + soloCtx *context.CliContext + executionEventRepository domain.ExecutionEventRepository + table table.Component +} + +func WithActive() Option { + return func(model *Model) { + model.active = true + } +} + +func NewModel( + soloCtx *context.CliContext, + executionEventRepository domain.ExecutionEventRepository, + opts ...Option, +) Model { + model := Model{ + soloCtx: soloCtx, + width: defaultWidth, + height: defaultHeight, + executionEventRepository: executionEventRepository, + table: table.NewComponent( + table.WithRowChangeCallback(func(selectedRow table.RowID) func() tea.Msg { + return func() tea.Msg { + return messages.ExecutionEventHighlighted{ + ExecutionEvent: string(selectedRow), + } + } + }), + table.WithRowSelectCallback(func(selectedRow table.RowID) func() tea.Msg { + return func() tea.Msg { + return messages.ExecutionEventSelected{ + ExecutionEvent: string(selectedRow), + } + } + }), + table.WithDimensions(defaultWidth, defaultHeight), + table.WithColumnOrder([]table.ColumnID{"DATE", "EVENT", "COMMAND", "STATUS", "ERROR"}), + table.WithColumns( + table.Columns{ + "DATE": {Title: "DATE", Width: minDateColumnWidth}, + "EVENT": {Title: "EVENT", Width: minEventColumnWidth}, + "COMMAND": {Title: "COMMAND", Width: minCommandColumnWidth}, + "STATUS": {Title: "STATUS", Width: minStatusColumnWidth}, + "ERROR": {Title: "ERROR", Width: minErrorColumnWidth}, + }, + ), + ), + } + + for _, opt := range opts { + opt(&model) + } + + return model +} + +func (t Model) Init() tea.Cmd { + return func() tea.Msg { + rows := make([]table.Row, 0) + basePath := t.soloCtx.Project.ResolveStateDirectory("audit_logs") + + for id, eventData := range t.executionEventRepository.ReverseWalk(basePath, "event.json") { + status := "failed" + if eventData.Error == "" { + status = "succeeded" + } + + eventDateTime, err := time.Parse("2006-01-02T15-04-05.999999999Z", id) + if err != nil { + return err + } + + rows = append(rows, table.Row{ + ID: table.RowID(id), + Cells: []table.Cell{ + {ColumnID: "DATE", Value: eventDateTime.Format(time.RFC1123)}, + {ColumnID: "EVENT", Value: eventData.CommandPath}, + {ColumnID: "COMMAND", Value: strings.Join(eventData.CommandArgs, " ")}, + {ColumnID: "STATUS", Value: status}, + {ColumnID: "ERROR", Value: eventData.Error}, + }, + }) + } + + return ExecutionEventHistoryDataLoaded{ + rows: rows, + } + } +} + +func (t Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch m := msg.(type) { + case messages.ComponentSizeMsg: + t.width, t.height = m.Width, m.Height + + t.table, cmd = t.table.Update(table.SetComponentSizeMsg{ + Width: t.width, + Height: t.height, + ColumnWidths: map[table.ColumnID]int{ + "DATE": max(minDateColumnWidth, (t.width/100)*20), + "EVENT": max(minEventColumnWidth, (t.width/100)*7), + "COMMAND": max(minCommandColumnWidth, (t.width/100)*16), + "STATUS": max(minStatusColumnWidth, (t.width/100)*7), + "ERROR": max(minErrorColumnWidth, (t.width/100)*50), + }, + }) + + cmds = append(cmds, cmd) + + case ExecutionEventHistoryDataLoaded: + t.table, cmd = t.table.Update(table.SetRowsMsg{Rows: m.rows}) + cmds = append(cmds, cmd) + + case messages.ExecutionEventSelected: + t.active = false + + case tea.KeyMsg: + if t.active == false { + break + } + + t.table, cmd = t.table.Update(msg) + cmds = append(cmds, cmd) + } + + return t, tea.Batch(cmds...) +} + +func (t Model) View() tea.View { + return tea.NewView(t.table.View().Content) +} diff --git a/internal/pkg/host/app/bubbletea/models/execution_event_overview/model.go b/internal/pkg/host/app/bubbletea/models/execution_event_overview/model.go new file mode 100644 index 00000000..f3ebd30b --- /dev/null +++ b/internal/pkg/host/app/bubbletea/models/execution_event_overview/model.go @@ -0,0 +1,76 @@ +package execution_event_overview + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/layout" + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/messages" + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/models/workflow_event_output" + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/models/workflow_event_tree" + "github.com/spaulg/solo/internal/pkg/host/domain" +) + +type Model struct { + layoutManager *layout.Manager + + width int + height int + + workflowEventTree tea.Model + workflowEventOutput tea.Model +} + +func NewModel(containerStepMapRepository domain.ContainerStepMapRepository) Model { + return Model{ + layoutManager: layout.NewLayoutManager( + layout.HorizontalLayoutDirection, + []layout.Spec{ + layout.NewPercentageLayoutSpec(25, lipgloss.NewStyle().Border(lipgloss.RoundedBorder())), + layout.NewFillLayoutSpec(lipgloss.NewStyle().Border(lipgloss.RoundedBorder())), + }, + ), + workflowEventTree: workflow_event_tree.NewModel(containerStepMapRepository), + workflowEventOutput: workflow_event_output.NewModel(), + } +} + +func (t Model) Init() tea.Cmd { + return nil +} + +func (t Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch m := msg.(type) { + case messages.ComponentSizeMsg: + t.width, t.height = m.Width, m.Height + + cmds = append(cmds, t.layoutManager.ResizeComponents( + t.width, + t.height, + &t.workflowEventTree, + &t.workflowEventOutput, + )...) + + default: + // Forward message + for _, model := range []*tea.Model{ + &t.workflowEventTree, + &t.workflowEventOutput, + } { + *model, cmd = (*model).Update(msg) + cmds = append(cmds, cmd) + } + } + + return t, tea.Batch(cmds...) +} + +func (t Model) View() tea.View { + return tea.NewView(t.layoutManager.Render( + t.workflowEventTree.View().Content, + t.workflowEventOutput.View().Content, + )) +} diff --git a/internal/pkg/host/app/bubbletea/models/workflow_event_output/model.go b/internal/pkg/host/app/bubbletea/models/workflow_event_output/model.go new file mode 100644 index 00000000..d0a35356 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/models/workflow_event_output/model.go @@ -0,0 +1,51 @@ +package workflow_event_output + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/messages" +) + +type Model struct { + width int + height int +} + +func NewModel() Model { + return Model{} +} + +func (t Model) Init() tea.Cmd { + return nil +} + +func (t Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m := msg.(type) { + case messages.ComponentSizeMsg: + t.width, t.height = m.Width, m.Height + + case messages.WorkflowEventSelected: + // todo: load the output from logs for the specified container and workflow name + // m.ExecutionEventName + // m.ContainerName + // m.WorkflowName + + // todo: load .solo/audit_logs/{ExecutionEventName}/{WorkflowName}/workflow.meta.json + // todo: use ContainerName in the loaded json to get executed steps + // todo: for each step, load the meta.json file and load the .out files + } + + return t, nil +} + +func (t Model) View() tea.View { + return tea.NewView( + lipgloss.NewStyle(). + Width(t.width). + MaxWidth(t.width). + Height(t.height). + MaxHeight(t.height). + Render("Output"), + ) +} diff --git a/internal/pkg/host/app/bubbletea/models/workflow_event_tree/model.go b/internal/pkg/host/app/bubbletea/models/workflow_event_tree/model.go new file mode 100644 index 00000000..08773602 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/models/workflow_event_tree/model.go @@ -0,0 +1,238 @@ +package workflow_event_tree + +import ( + "sort" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/tree" + + "github.com/spaulg/solo/internal/pkg/host/domain" + + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/messages" +) + +const ( + containerFirstOrientation = iota + workflowFirstOrientation +) + +type Orientation int + +type Model struct { + width int + height int + active bool + + containerStepMapRepository domain.ContainerStepMapRepository + + executionEventName string + + orientation Orientation + workflowContainerMap map[string]map[string][]string + + keyMap KeyMap + styles Styles +} + +type WorkflowMeta map[string][]string + +type WorkflowEventLoaded struct { + executionEvent string + workflowContainerMap map[string]map[string][]string +} + +func DefaultStyles() Styles { + return Styles{ + Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")), + Node: lipgloss.NewStyle(), + } +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + LineUp: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + LineDown: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + } +} + +func NewModel(containerStepMapRepository domain.ContainerStepMapRepository) Model { + return Model{ + containerStepMapRepository: containerStepMapRepository, + workflowContainerMap: make(map[string]map[string][]string), + orientation: containerFirstOrientation, + styles: DefaultStyles(), + keyMap: DefaultKeyMap(), + } +} + +func (t Model) Init() tea.Cmd { + return nil +} + +func (t Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + switch m := msg.(type) { + case messages.ComponentSizeMsg: + t.width, t.height = m.Width, m.Height + + case messages.ExecutionEventHighlighted: + cmds = append(cmds, func() tea.Msg { + workflowContainerMap := make(map[string]map[string][]string) + + // todo: validate that the directory being loaded is for a workflow + + filepath := ".solo/audit_logs/" + m.ExecutionEvent + for workflowName, containerStepMap := range t.containerStepMapRepository.Walk(filepath, "container_step_map.meta.json") { + if t.orientation == containerFirstOrientation { + // container name > workflow name > steps + for containerName, steps := range containerStepMap { + if _, ok := workflowContainerMap[containerName]; !ok { + workflowContainerMap[containerName] = make(map[string][]string) + } + + workflowContainerMap[containerName][workflowName] = steps + } + } else { + // workflow name > container name > steps + if _, ok := workflowContainerMap[workflowName]; !ok { + workflowContainerMap[workflowName] = make(map[string][]string) + } + + for containerName, steps := range containerStepMap { + workflowContainerMap[workflowName][containerName] = steps + } + } + } + + return WorkflowEventLoaded{ + executionEvent: m.ExecutionEvent, + workflowContainerMap: workflowContainerMap, + } + }) + + case messages.ExecutionEventSelected: + t.active = true + + case WorkflowEventLoaded: + t.executionEventName = m.executionEvent + t.workflowContainerMap = m.workflowContainerMap + + case tea.KeyMsg: + //treeChanged := false + // + //switch { + //case key.Matches(m, t.keyMap.LineUp): + // treeChanged = t.selectPrevRow() + //case key.Matches(m, t.keyMap.LineDown): + // treeChanged = t.selectNextRow() + //} + // + //if treeChanged { + //containerName := t.workflowNames[t.selectedContainerIndex] + // + //workflowName := "" + //if t.selectedWorkflowIndex >= 0 { + // workflowName = t.containerWorkflowMap[containerName][t.selectedWorkflowIndex] + //} + + //cmds = append(cmds, func() tea.Msg { + // return messages.WorkflowEventSelected{ + // ExecutionEventName: t.executionEventName, + // ContainerName: containerName, + // WorkflowName: workflowName, + // } + //}) + //} + } + + return t, tea.Batch(cmds...) +} + +func (t Model) View() tea.View { + treeView := tree.Root("") + + level1Names := make([]string, 0) + for level1Name := range t.workflowContainerMap { + level1Names = append(level1Names, level1Name) + } + + sort.Strings(level1Names) + + for _, level1Name := range level1Names { + child := tree.New() + + rootLabel := level1Name + //if t.selectedContainerIndex == workflowNameIndex && t.selectedWorkflowIndex == -1 { + // rootLabel = t.styles.Selected.Render(workflowName) + //} + + child = child.Root(rootLabel) + + level2Names := make([]string, 0) + for level2Name := range t.workflowContainerMap[level1Name] { + level2Names = append(level2Names, level2Name) + } + + sort.Strings(level2Names) + + for _, level2Name := range level2Names { + childLabel := level2Name + // if t.selectedContainerIndex == workflowNameIndex && t.selectedWorkflowIndex == eventIndex { + // eventLabel = t.styles.Selected.Render(eventLabel) + // } + + child.Child(childLabel) + } + + treeView.Child(child) + } + + return tea.NewView( + lipgloss.NewStyle(). + Width(t.width). + MaxWidth(t.width). + Height(t.height). + MaxHeight(t.height). + Render(treeView.String()), + ) +} + +//func (t *Model) selectPrevRow() bool { +// if t.selectedContainerIndex > 0 && t.selectedWorkflowIndex == -1 { +// t.selectedContainerIndex-- +// t.selectedWorkflowIndex = len(t.containerWorkflowMap[t.workflowNames[t.selectedContainerIndex]]) - 1 +// +// return true +// } else if t.selectedWorkflowIndex >= 0 { +// t.selectedWorkflowIndex-- +// +// return true +// } +// +// return false +//} +// +//func (t *Model) selectNextRow() bool { +// containerName := t.workflowNames[t.selectedContainerIndex] +// +// if t.selectedWorkflowIndex < len(t.containerWorkflowMap[containerName])-1 { +// t.selectedWorkflowIndex++ +// +// return true +// } else if t.selectedContainerIndex < len(t.workflowNames)-1 { +// t.selectedContainerIndex++ +// t.selectedWorkflowIndex = -1 +// +// return true +// } +// +// return false +//} diff --git a/internal/pkg/host/app/bubbletea/models/workflow_event_tree/types.go b/internal/pkg/host/app/bubbletea/models/workflow_event_tree/types.go new file mode 100644 index 00000000..be22ebf5 --- /dev/null +++ b/internal/pkg/host/app/bubbletea/models/workflow_event_tree/types.go @@ -0,0 +1,16 @@ +package workflow_event_tree + +import ( + "charm.land/bubbles/v2/key" + "charm.land/lipgloss/v2" +) + +type Styles struct { + Node lipgloss.Style + Selected lipgloss.Style +} + +type KeyMap struct { + LineUp key.Binding + LineDown key.Binding +} diff --git a/internal/pkg/host/app/bubbletea/views/workflow_log/view.go b/internal/pkg/host/app/bubbletea/views/workflow_log/view.go new file mode 100644 index 00000000..9b35133f --- /dev/null +++ b/internal/pkg/host/app/bubbletea/views/workflow_log/view.go @@ -0,0 +1,112 @@ +package workflow_log + +import ( + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/components/heading" + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/layout" + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/models/execution_event_history" + "github.com/spaulg/solo/internal/pkg/host/app/bubbletea/models/execution_event_overview" + "github.com/spaulg/solo/internal/pkg/host/app/context" + "github.com/spaulg/solo/internal/pkg/host/domain" + "github.com/spaulg/solo/internal/pkg/host/infra/repository" +) + +type View struct { + soloCtx *context.CliContext + layoutManager *layout.Manager + + width int + height int + + headingWidth int + headingHeight int + headingStyle lipgloss.Style + + heading tea.Model + executionEventHistory tea.Model + executionEventOverview tea.Model +} + +func NewView(soloCtx *context.CliContext) (tea.Model, error) { + executionEventRepository := repository.NewJSONFileRepository[*domain.ExecutionEvent]() + containerStepMapRepository := repository.NewJSONFileRepository[domain.ContainerStepMap]() + + return View{ + soloCtx: soloCtx, + layoutManager: layout.NewLayoutManager( + layout.VerticalLayoutDirection, + []layout.Spec{ + layout.NewFixedLayoutSpec(1, lipgloss.NewStyle()), + layout.NewPercentageLayoutSpec(50, lipgloss.NewStyle().Border(lipgloss.RoundedBorder())), + layout.NewFillLayoutSpec(lipgloss.NewStyle()), + }, + ), + heading: heading.NewComponent("Workflow Logs"), + executionEventHistory: execution_event_history.NewModel( + soloCtx, + executionEventRepository, + execution_event_history.WithActive(), + ), + executionEventOverview: execution_event_overview.NewModel( + containerStepMapRepository, + ), + }, nil +} + +func (t View) Init() tea.Cmd { + return tea.Batch( + t.heading.Init(), + t.executionEventHistory.Init(), + t.executionEventOverview.Init(), + ) +} + +func (t View) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch m := msg.(type) { + case tea.WindowSizeMsg: + t.width = m.Width + t.height = m.Height + + cmds = append(cmds, t.layoutManager.ResizeComponents( + t.width, + t.height, + &t.heading, + &t.executionEventHistory, + &t.executionEventOverview, + )...) + + default: + if m, ok := msg.(tea.KeyMsg); ok && m.String() == "ctrl+c" { + return t, tea.Quit + } + + // Forward message + for _, model := range []*tea.Model{ + &t.heading, + &t.executionEventHistory, + &t.executionEventOverview, + } { + *model, cmd = (*model).Update(msg) + cmds = append(cmds, cmd) + } + } + + return t, tea.Batch(cmds...) +} + +func (t View) View() tea.View { + v := tea.NewView(t.layoutManager.Render( + t.heading.View().Content, + t.executionEventHistory.View().Content, + t.executionEventOverview.View().Content, + )) + + v.AltScreen = true + + return v +} diff --git a/internal/pkg/host/app/project_control_factory.go b/internal/pkg/host/app/project_control_factory.go index 0215367b..541cc25d 100644 --- a/internal/pkg/host/app/project_control_factory.go +++ b/internal/pkg/host/app/project_control_factory.go @@ -6,20 +6,20 @@ import ( "github.com/spaulg/solo/internal/pkg/host/app/audit" "github.com/spaulg/solo/internal/pkg/host/app/context" "github.com/spaulg/solo/internal/pkg/host/app/event_manager" - wms2 "github.com/spaulg/solo/internal/pkg/host/app/wms" - domain2 "github.com/spaulg/solo/internal/pkg/host/domain" + "github.com/spaulg/solo/internal/pkg/host/app/wms" + "github.com/spaulg/solo/internal/pkg/host/domain" "github.com/spaulg/solo/internal/pkg/host/infra/certificate/self_signed" "github.com/spaulg/solo/internal/pkg/host/infra/container" "github.com/spaulg/solo/internal/pkg/host/infra/grpc" - repository2 "github.com/spaulg/solo/internal/pkg/host/infra/repository" + "github.com/spaulg/solo/internal/pkg/host/infra/repository" ) func ProjectControlFactory(soloCtx *context.CliContext) (*ProjectControl, error) { - execEventRepository := repository2.NewJSONFileRepository[*domain2.ExecutionEvent]() - workflowLogMetaRepository := repository2.NewJSONFileRepository[domain2.WorkflowLogMeta]() - workflowStepLogMetaRepository := repository2.NewJSONFileRepository[*domain2.WorkflowStepLogMeta]() - workflowExecTraceRepository := repository2.NewJSONFileRepository[*domain2.WorkflowExecTrace]() - logWriter := repository2.NewAppendFileStore() + execEventRepository := repository.NewJSONFileRepository[*domain.ExecutionEvent]() + containerStepMapRepository := repository.NewJSONFileRepository[domain.ContainerStepMap]() + workflowStepLogMetaRepository := repository.NewJSONFileRepository[*domain.WorkflowStepLogMeta]() + workflowExecTraceRepository := repository.NewJSONFileRepository[*domain.WorkflowExecTrace]() + logWriter := repository.NewAppendFileStore() auditor := audit.NewAuditor( soloCtx, @@ -27,7 +27,7 @@ func ProjectControlFactory(soloCtx *context.CliContext) (*ProjectControl, error) soloCtx.Config, soloCtx.Project, execEventRepository, - workflowLogMetaRepository, + containerStepMapRepository, workflowStepLogMetaRepository, logWriter, ) @@ -45,12 +45,12 @@ func ProjectControlFactory(soloCtx *context.CliContext) (*ProjectControl, error) return nil, fmt.Errorf("failed to initialize certificate authority: %w", err) } - workflowFactory := wms2.NewWorkflowFactory() - workflowRunner := wms2.NewWorkflowRunner(soloCtx.Config, soloCtx.Project, eventManager, workflowFactory) + workflowFactory := wms.NewWorkflowFactory() + workflowRunner := wms.NewWorkflowRunner(soloCtx.Config, soloCtx.Project, eventManager, workflowFactory) grpcServerFactory := grpc.NewMutualTLSServerFactory(soloCtx.Logger, soloCtx.Project, certificateAuthority, workflowRunner) - workflowGuardFactory := wms2.NewWorkflowGuardFactory(soloCtx.Logger, soloCtx.Config, soloCtx.Project) + workflowGuardFactory := wms.NewWorkflowGuardFactory(soloCtx.Logger, soloCtx.Config, soloCtx.Project) // Project control projectControl := NewProjectControl( diff --git a/internal/pkg/host/domain/container_step_map.go b/internal/pkg/host/domain/container_step_map.go new file mode 100644 index 00000000..d27bf282 --- /dev/null +++ b/internal/pkg/host/domain/container_step_map.go @@ -0,0 +1,11 @@ +package domain + +type ContainerStepMap map[string][]string + +func NewWorkflowContainerStepMap() ContainerStepMap { + return make(ContainerStepMap) +} + +func (t ContainerStepMap) AppendStep(containerName string, stepID string) { + t[containerName] = append(t[containerName], stepID) +} diff --git a/internal/pkg/host/domain/container_step_map_repository.go b/internal/pkg/host/domain/container_step_map_repository.go new file mode 100644 index 00000000..1486b4e0 --- /dev/null +++ b/internal/pkg/host/domain/container_step_map_repository.go @@ -0,0 +1,5 @@ +package domain + +type ContainerStepMapRepository interface { + EntityRepository[ContainerStepMap] +} diff --git a/internal/pkg/host/domain/entity_repository.go b/internal/pkg/host/domain/entity_repository.go index 16f888ab..5403a1c1 100644 --- a/internal/pkg/host/domain/entity_repository.go +++ b/internal/pkg/host/domain/entity_repository.go @@ -1,6 +1,12 @@ package domain +import ( + "iter" +) + type EntityRepository[T any] interface { + Walk(filePath string, filename string) iter.Seq2[string, T] + ReverseWalk(filePath string, filename string) iter.Seq2[string, T] Save(filePath string, entity T) error Load(filePath string) (T, error) } diff --git a/internal/pkg/host/domain/workflow_log_meta.go b/internal/pkg/host/domain/workflow_log_meta.go deleted file mode 100644 index 15b6ec89..00000000 --- a/internal/pkg/host/domain/workflow_log_meta.go +++ /dev/null @@ -1,11 +0,0 @@ -package domain - -type WorkflowLogMeta map[string][]string - -func NewWorkflowLogMeta() WorkflowLogMeta { - return make(WorkflowLogMeta) -} - -func (t WorkflowLogMeta) AppendStep(containerName string, stepID string) { - t[containerName] = append(t[containerName], stepID) -} diff --git a/internal/pkg/host/domain/workflow_log_meta_repository.go b/internal/pkg/host/domain/workflow_log_meta_repository.go deleted file mode 100644 index 622b25ca..00000000 --- a/internal/pkg/host/domain/workflow_log_meta_repository.go +++ /dev/null @@ -1,5 +0,0 @@ -package domain - -type WorkflowLogMetaRepository interface { - EntityRepository[WorkflowLogMeta] -} diff --git a/internal/pkg/host/infra/repository/json_file_repository.go b/internal/pkg/host/infra/repository/json_file_repository.go index 96b95794..85251447 100644 --- a/internal/pkg/host/infra/repository/json_file_repository.go +++ b/internal/pkg/host/infra/repository/json_file_repository.go @@ -4,8 +4,10 @@ import ( "encoding/json" "errors" "fmt" + "iter" "os" "path" + "path/filepath" ) type JSONFileRepository[T any] struct{} @@ -14,6 +16,14 @@ func NewJSONFileRepository[T any]() *JSONFileRepository[T] { return &JSONFileRepository[T]{} } +func (t *JSONFileRepository[T]) Walk(filePath string, filename string) iter.Seq2[string, T] { + return t.walk(filePath, filename, false) +} + +func (t *JSONFileRepository[T]) ReverseWalk(filePath string, filename string) iter.Seq2[string, T] { + return t.walk(filePath, filename, true) +} + func (t *JSONFileRepository[T]) Save(filePath string, entity T) error { data, err := json.MarshalIndent(entity, "", " ") if err != nil { @@ -82,3 +92,32 @@ func (t *JSONFileRepository[T]) Load(filePath string) (T, error) { return entity, nil } + +func (t *JSONFileRepository[T]) walk(filePath string, filename string, reverse bool) iter.Seq2[string, T] { + return func(yield func(string, T) bool) { + entries, err := os.ReadDir(filePath) + if err != nil { + return + } + + if reverse { + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + executionEventPath := filepath.Join(filePath, entry.Name(), filename) + eventData, err := t.Load(executionEventPath) + if err != nil { + return + } + + yield(entry.Name(), eventData) + } + } +} diff --git a/test/mocks/host/infra/repository/json_file_repository.go b/test/mocks/host/infra/repository/json_file_repository.go index 8fce50b3..2b4ecaf1 100644 --- a/test/mocks/host/infra/repository/json_file_repository.go +++ b/test/mocks/host/infra/repository/json_file_repository.go @@ -1,6 +1,8 @@ package repository import ( + "iter" + "github.com/stretchr/testify/mock" ) @@ -8,6 +10,16 @@ type MockJSONFileRepository[T any] struct { mock.Mock } +func (m *MockJSONFileRepository[T]) Walk(filePath string, filename string) iter.Seq2[string, T] { + args := m.Called(filePath, filename) + return args.Get(0).(iter.Seq2[string, T]) +} + +func (m *MockJSONFileRepository[T]) ReverseWalk(filePath string, filename string) iter.Seq2[string, T] { + args := m.Called(filePath, filename) + return args.Get(0).(iter.Seq2[string, T]) +} + func (m *MockJSONFileRepository[T]) Save(filePath string, entity T) error { args := m.Called(filePath, entity) return args.Error(0)