From 95005c0cffd6b69182a5c78e1e4ca95fcb30a883 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 13 Dec 2025 19:47:39 +0800 Subject: [PATCH] :sparkles: Better log viewer in launchpad --- go.mod | 17 +++ go.sum | 38 ++++++ pkg/launchpad/dev/dev.go | 92 +++++++------- pkg/launchpad/interactive/dev_mode.go | 126 +++++++++++++++++++ pkg/launchpad/logview/viewer.go | 171 ++++++++++++++++++++++++++ pkg/launchpad/main.go | 44 ++++++- pkg/shared/hash/mod.go | 9 ++ 7 files changed, 452 insertions(+), 45 deletions(-) create mode 100644 pkg/launchpad/interactive/dev_mode.go create mode 100644 pkg/launchpad/logview/viewer.go create mode 100644 pkg/shared/hash/mod.go diff --git a/go.mod b/go.mod index 38d8f05..f0827de 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module git.solsynth.dev/goatworks/turbine go 1.25.5 require ( + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/gofiber/fiber/v3 v3.0.0-rc.3 github.com/joho/godotenv v1.5.1 github.com/rs/zerolog v1.34.0 @@ -13,8 +16,14 @@ require ( require ( github.com/andybalholm/brotli v1.2.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofiber/schema v1.6.0 // indirect @@ -24,10 +33,17 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/klauspost/compress v1.18.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/philhofer/fwd v1.2.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect @@ -37,6 +53,7 @@ require ( github.com/tinylib/msgp v1.5.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.68.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.etcd.io/etcd/api/v3 v3.6.6 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.6 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index 5e64732..2b73df6 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,29 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -45,6 +63,8 @@ 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.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -52,6 +72,16 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= @@ -59,6 +89,9 @@ github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -90,6 +123,8 @@ github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFn github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +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= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -125,6 +160,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -139,6 +176,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/launchpad/dev/dev.go b/pkg/launchpad/dev/dev.go index ebdd21a..f9654cf 100644 --- a/pkg/launchpad/dev/dev.go +++ b/pkg/launchpad/dev/dev.go @@ -15,6 +15,7 @@ import ( "time" "git.solsynth.dev/goatworks/turbine/pkg/launchpad/config" + "git.solsynth.dev/goatworks/turbine/pkg/launchpad/logview" "github.com/rs/zerolog/log" ) @@ -22,20 +23,25 @@ var colors = []string{ "\033[32m", "\033[33m", "\033[34m", "\033[35m", "\033[36m", "\033[31m", } -const colorReset = "\033[0m" - -// RunDev starts all services defined in the config in development mode. -func RunDev(cfg config.LaunchpadConfig) { +// RunDev starts selected services defined in the config in development mode. +func RunDev(cfg config.LaunchpadConfig, servicesToStart []config.Service) { log.Info().Msg("Starting services in development mode with dependency checks...") ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup + defer func() { + log.Info().Msg("Shutting down all services...") + cancel() + wg.Wait() + log.Info().Msg("All services have been shut down.") + }() + sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) go func() { - sig := <-sigChan - log.Info().Msgf("Received signal: %v. Shutting down all services...", sig) + <-sigChan + log.Info().Msg("Shutdown signal received.") cancel() }() @@ -48,33 +54,43 @@ func RunDev(cfg config.LaunchpadConfig) { } createDockerNetwork(devNetwork) - // --- Dependency-aware startup --- serviceMap := make(map[string]config.Service) for _, s := range cfg.Services { serviceMap[s.Name] = s } started := make(map[string]chan bool) - for _, s := range cfg.Services { + serviceNamesToStart := []string{} + for _, s := range servicesToStart { + serviceNamesToStart = append(serviceNamesToStart, s.Name) + } + log.Info().Msgf("Attempting to start: %s", strings.Join(serviceNamesToStart, ", ")) + + logChan := make(chan logview.LogMessage, 100) + + for i, s := range servicesToStart { if _, exists := started[s.Name]; !exists { - startServiceWithDeps(ctx, &wg, s, serviceMap, started, devNetwork) + color := colors[i%len(colors)] + startServiceWithDeps(ctx, &wg, s, serviceMap, started, devNetwork, color, logChan) + } + } + + // Don't start the log viewer if no services were selected to run + if len(servicesToStart) > 0 { + if err := logview.Start(logChan, serviceNamesToStart); err != nil { + log.Fatal().Err(err).Msg("Log viewer failed") } } - - wg.Wait() - log.Info().Msg("All services have been shut down.") } -func startServiceWithDeps(ctx context.Context, wg *sync.WaitGroup, s config.Service, serviceMap map[string]config.Service, started map[string]chan bool, network string) { +func startServiceWithDeps(ctx context.Context, wg *sync.WaitGroup, s config.Service, serviceMap map[string]config.Service, started map[string]chan bool, network, color string, logChan chan<- logview.LogMessage) { if _, exists := started[s.Name]; exists { return } - // Create a channel that will be closed when this service is healthy healthyChan := make(chan bool) started[s.Name] = healthyChan - // First, recursively start dependencies var depNames []string for depName := range s.Prod.DependsOn { depNames = append(depNames, depName) @@ -82,11 +98,11 @@ func startServiceWithDeps(ctx context.Context, wg *sync.WaitGroup, s config.Serv for _, depName := range depNames { if dep, ok := serviceMap[depName]; ok { - startServiceWithDeps(ctx, wg, dep, serviceMap, started, network) + // Dependencies get a default color for now + startServiceWithDeps(ctx, wg, dep, serviceMap, started, network, "\033[37m", logChan) } } - // Wait for dependencies to be healthy log.Info().Str("service", s.Name).Msgf("Waiting for dependencies to be healthy: %v", depNames) for _, depName := range depNames { if depChan, ok := started[depName]; ok { @@ -101,33 +117,29 @@ func startServiceWithDeps(ctx context.Context, wg *sync.WaitGroup, s config.Serv } } - // Now, start the actual service wg.Add(1) - go func(s config.Service, color string) { + go func() { defer wg.Done() var healthCheckPorts []int if s.Type == "docker" { - // For docker, we use the dev healthcheck ports to also map them healthCheckPorts = s.Dev.Healthcheck.TcpPorts - startDockerService(ctx, s, color, network, healthCheckPorts) + startDockerService(ctx, s, color, network, healthCheckPorts, logChan) } else if s.Dev.Command != "" { healthCheckPorts = s.Dev.Healthcheck.TcpPorts - startSourceService(ctx, s, color) + startSourceService(ctx, s, color, logChan) } else { log.Warn().Str("service", s.Name).Msg("No dev.command or docker type, skipping.") - close(healthyChan) // Mark as "healthy" so other things can proceed + close(healthyChan) return } - // Perform health check on the service we just started waitForHealth(ctx, s.Name, healthCheckPorts) - close(healthyChan) // Signal that this service is now healthy + close(healthyChan) - // Block until context is cancelled to keep the service running - // and ensure wg.Done is called at the right time. <-ctx.Done() - }(s, colors[len(started)%len(colors)]) + }( + ) } func waitForHealth(ctx context.Context, serviceName string, ports []int) { @@ -158,9 +170,7 @@ func waitForHealth(ctx context.Context, serviceName string, ports []int) { } } -// startSourceService runs a service from its source code. -func startSourceService(ctx context.Context, s config.Service, color string) { - prefix := fmt.Sprintf("%s[%-10s]%s ", color, s.Name, colorReset) +func startSourceService(ctx context.Context, s config.Service, color string, logChan chan<- logview.LogMessage) { log.Info().Str("service", s.Name).Str("command", s.Dev.Command).Msg("Starting from source") parts := strings.Fields(s.Dev.Command) @@ -173,12 +183,10 @@ func startSourceService(ctx context.Context, s config.Service, color string) { } cmd.Env = env - runAndMonitorCommand(ctx, cmd, s.Name, prefix) + runAndMonitorCommand(ctx, cmd, s.Name, color, logChan) } -// startDockerService runs a pre-built Docker image. -func startDockerService(ctx context.Context, s config.Service, color string, network string, portsToMap []int) { - prefix := fmt.Sprintf("%s[%-10s]%s ", color, s.Name, colorReset) +func startDockerService(ctx context.Context, s config.Service, color string, network string, portsToMap []int, logChan chan<- logview.LogMessage) { log.Info().Str("service", s.Name).Str("image", s.Prod.Image).Msg("Starting from Docker image") containerName := fmt.Sprintf("%s-dev", s.Name) @@ -218,10 +226,10 @@ func startDockerService(ctx context.Context, s config.Service, color string, net }() cmd := exec.Command("docker", args...) - runAndMonitorCommand(ctx, cmd, s.Name, prefix) + runAndMonitorCommand(ctx, cmd, s.Name, color, logChan) } -func runAndMonitorCommand(ctx context.Context, cmd *exec.Cmd, serviceName, prefix string) { +func runAndMonitorCommand(ctx context.Context, cmd *exec.Cmd, serviceName, color string, logChan chan<- logview.LogMessage) { stdout, _ := cmd.StdoutPipe() stderr, _ := cmd.StderrPipe() @@ -230,14 +238,14 @@ func runAndMonitorCommand(ctx context.Context, cmd *exec.Cmd, serviceName, prefi return } - go streamOutput(stdout, prefix) - go streamOutput(stderr, prefix) + go streamOutput(stdout, serviceName, color, logChan) + go streamOutput(stderr, serviceName, color, logChan) go func() { err := cmd.Wait() if ctx.Err() != nil { - log.Info().Str("service", serviceName).Msg("Process stopped.") + // This is expected on clean shutdown } else if err != nil { log.Error().Err(err).Str("service", serviceName).Msg("Exited with error") } else { @@ -246,10 +254,10 @@ func runAndMonitorCommand(ctx context.Context, cmd *exec.Cmd, serviceName, prefi }() } -func streamOutput(pipe io.ReadCloser, prefix string) { +func streamOutput(pipe io.ReadCloser, serviceName, color string, logChan chan<- logview.LogMessage) { scanner := bufio.NewScanner(pipe) for scanner.Scan() { - fmt.Printf("%s%s\n", prefix, scanner.Text()) + logChan <- logview.LogMessage{ServiceName: serviceName, Line: scanner.Text(), Color: color} } } diff --git a/pkg/launchpad/interactive/dev_mode.go b/pkg/launchpad/interactive/dev_mode.go new file mode 100644 index 0000000..608e0e6 --- /dev/null +++ b/pkg/launchpad/interactive/dev_mode.go @@ -0,0 +1,126 @@ +package interactive + +import ( + "fmt" + "strings" + + "git.solsynth.dev/goatworks/turbine/pkg/launchpad/config" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("62")). + Foreground(lipgloss.Color("230")). + Padding(0, 1) + + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) +) + +type model struct { + services []config.Service + cursor int + selected map[int]struct{} + startServices bool +} + +func initialModel(services []config.Service) model { + selected := make(map[int]struct{}) + for i := range services { + selected[i] = struct{}{} + } + return model{ + services: services, + selected: selected, + } +} + +func (m 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 "ctrl+c", "q": + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + case "down", "j": + if m.cursor < len(m.services)-1 { + m.cursor++ + } + + case " ": + if _, ok := m.selected[m.cursor]; ok { + delete(m.selected, m.cursor) + } else { + m.selected[m.cursor] = struct{}{} + } + case "enter": + m.startServices = true + return m, tea.Quit + } + } + return m, nil +} + +func (m model) View() string { + var b strings.Builder + + b.WriteString(titleStyle.Render("Select services to start")) + b.WriteString("\n\n") + + for i, service := range m.services { + cursor := " " + if m.cursor == i { + cursor = ">" + } + + checked := " " + if _, ok := m.selected[i]; ok { + checked = "x" + } + + b.WriteString(fmt.Sprintf("%s [%s] %s\n", cursor, checked, service.Name)) + } + + b.WriteString("\n") + b.WriteString(helpStyle.Render("Use up/down to navigate, space to select, enter to start, q to quit.")) + + return b.String() +} + +func (m model) SelectedServices() []config.Service { + var selectedServices []config.Service + for i := range m.selected { + selectedServices = append(selectedServices, m.services[i]) + } + return selectedServices +} + +func SelectServices(services []config.Service) ([]config.Service, error) { + if len(services) == 0 { + return nil, fmt.Errorf("no services defined in launchpad.toml") + } + m := initialModel(services) + p := tea.NewProgram(m) + + finalModel, err := p.Run() + if err != nil { + return nil, err + } + + fm := finalModel.(model) + if fm.startServices { + return fm.SelectedServices(), nil + } + + return nil, nil // User quit +} diff --git a/pkg/launchpad/logview/viewer.go b/pkg/launchpad/logview/viewer.go new file mode 100644 index 0000000..845c537 --- /dev/null +++ b/pkg/launchpad/logview/viewer.go @@ -0,0 +1,171 @@ +package logview + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// LogMessage is a message containing a line from a service's log. +type LogMessage struct { + ServiceName string + Line string + Color string +} + +type model struct { + services []string + logChan <-chan LogMessage + logs map[string][]string // Key: service name, Value: log lines + allLogs []string + viewports map[string]viewport.Model + activeTab int + ready bool + width int + height int +} + +var ( + tabStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).Padding(0, 1) + activeTabStyle = tabStyle.Copy().Border(lipgloss.ThickBorder(), false, false, true, false).Foreground(lipgloss.Color("62")) + inactiveTabStyle = tabStyle.Copy() + windowStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder()).Padding(1, 0) +) + +func NewModel(logChan <-chan LogMessage, services []string) model { + m := model{ + services: append([]string{"All"}, services...), + logChan: logChan, + logs: make(map[string][]string), + viewports: make(map[string]viewport.Model), + activeTab: 0, + } + for _, s := range m.services { + m.logs[s] = []string{} + } + return m +} + +func (m model) Init() tea.Cmd { + return m.waitForLogs() +} + +// waitForLogs waits for the next log message from the channel. +func (m model) waitForLogs() tea.Cmd { + return func() tea.Msg { + return <-m.logChan + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "right", "l": + m.activeTab = (m.activeTab + 1) % len(m.services) + m.updateViewportContent() + case "left", "h": + m.activeTab-- + if m.activeTab < 0 { + m.activeTab = len(m.services) - 1 + } + m.updateViewportContent() + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + headerHeight := lipgloss.Height(m.renderHeader()) + vpHeight := m.height - headerHeight - windowStyle.GetVerticalFrameSize() + + if !m.ready { + for _, serviceName := range m.services { + m.viewports[serviceName] = viewport.New(m.width-windowStyle.GetHorizontalFrameSize(), vpHeight) + } + m.ready = true + } else { + for _, serviceName := range m.services { + vp := m.viewports[serviceName] + vp.Width = m.width - windowStyle.GetHorizontalFrameSize() + vp.Height = vpHeight + m.viewports[serviceName] = vp + } + } + m.updateViewportContent() + + case LogMessage: + logLine := fmt.Sprintf("%s%s%s", msg.Color, msg.Line, "\033[0m") + allLogLine := fmt.Sprintf("%s[%-10s]%s %s", msg.Color, msg.ServiceName, "\033[0m", msg.Line) + + m.logs[msg.ServiceName] = append(m.logs[msg.ServiceName], logLine) + m.allLogs = append(m.allLogs, allLogLine) + + m.updateViewportContent() + cmds = append(cmds, m.waitForLogs()) + } + + activeViewport := m.viewports[m.services[m.activeTab]] + activeViewport, cmd = activeViewport.Update(msg) + m.viewports[m.services[m.activeTab]] = activeViewport + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m *model) updateViewportContent() { + if !m.ready { + return + } + activeServiceName := m.services[m.activeTab] + var content string + if activeServiceName == "All" { + content = strings.Join(m.allLogs, "\n") + } else { + content = strings.Join(m.logs[activeServiceName], "\n") + } + + vp := m.viewports[activeServiceName] + vp.SetContent(content) + vp.GotoBottom() + m.viewports[activeServiceName] = vp +} + +func (m model) View() string { + if !m.ready { + return "Initializing..." + } + header := m.renderHeader() + activeService := m.services[m.activeTab] + viewport := m.viewports[activeService] + + return fmt.Sprintf("%s\n%s", header, windowStyle.Render(viewport.View())) +} + +func (m model) renderHeader() string { + var tabs []string + for i, service := range m.services { + if i == m.activeTab { + tabs = append(tabs, activeTabStyle.Render(service)) + } else { + tabs = append(tabs, inactiveTabStyle.Render(service)) + } + } + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) +} + +// Start runs the log viewer program. +func Start(logChan <-chan LogMessage, services []string) error { + m := NewModel(logChan, services) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) + return p.Start() +} diff --git a/pkg/launchpad/main.go b/pkg/launchpad/main.go index fbc60c3..6916532 100644 --- a/pkg/launchpad/main.go +++ b/pkg/launchpad/main.go @@ -6,7 +6,7 @@ import ( "git.solsynth.dev/goatworks/turbine/pkg/launchpad/config" "git.solsynth.dev/goatworks/turbine/pkg/launchpad/deploy" "git.solsynth.dev/goatworks/turbine/pkg/launchpad/dev" - + "git.solsynth.dev/goatworks/turbine/pkg/launchpad/interactive" "github.com/joho/godotenv" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -14,7 +14,7 @@ import ( func init() { // Initialize logging - log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "15:04:05"}) } func main() { @@ -35,7 +35,7 @@ func main() { // Dispatch to the correct handler switch command { case "dev": - dev.RunDev(cfg) + handleDev(cfg, os.Args[2:]) case "deploy": log.Info().Msg("Generating docker-compose.yml for production deployment...") deploy.GenerateDockerCompose(cfg) @@ -43,3 +43,41 @@ func main() { log.Fatal().Msgf("Unknown command: %s", command) } } + +func handleDev(cfg config.LaunchpadConfig, args []string) { + var servicesToRun []config.Service + + if len(args) == 0 { + // Interactive mode + selectedServices, err := interactive.SelectServices(cfg.Services) + if err != nil { + log.Fatal().Err(err).Msg("Could not start interactive selection") + } + if len(selectedServices) == 0 { + log.Info().Msg("No services selected. Exiting.") + return + } + servicesToRun = selectedServices + } else if len(args) == 1 && args[0] == "all" { + log.Info().Msg("Starting all services.") + servicesToRun = cfg.Services + } else { + // Start specific services from args + serviceMap := make(map[string]config.Service) + for _, s := range cfg.Services { + serviceMap[s.Name] = s + } + + for _, arg := range args { + if service, ok := serviceMap[arg]; ok { + servicesToRun = append(servicesToRun, service) + } else { + log.Fatal().Msgf("Service '%s' not found in launchpad.toml", arg) + } + } + } + + if len(servicesToRun) > 0 { + dev.RunDev(cfg, servicesToRun) + } +} diff --git a/pkg/shared/hash/mod.go b/pkg/shared/hash/mod.go new file mode 100644 index 0000000..1ecda18 --- /dev/null +++ b/pkg/shared/hash/mod.go @@ -0,0 +1,9 @@ +package hash + +import "hash/fnv" + +func Hash(s string) uint32 { + h := fnv.New32a() + h.Write([]byte(s)) + return h.Sum32() +}