diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index 94c5f042..52f14c3c 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -398,8 +398,8 @@

Expert Settings below - only change if you know what y
Automatically update StationeersLaunchPad
diff --git a/go.mod b/go.mod index 679eae5c..c6e72c18 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,37 @@ go 1.25.0 require ( github.com/bwmarrin/discordgo v0.28.1 + github.com/charmbracelet/bubbles v0.21.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/fsnotify/fsnotify v1.7.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/jacksonthemaster/discordrichpresence v1.1.0 + github.com/mattn/go-isatty v0.0.20 golang.org/x/crypto v0.37.0 - golang.org/x/sys v0.35.0 + golang.org/x/sys v0.38.0 ) -require github.com/gorilla/websocket v1.5.3 // indirect +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // 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/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum index 870eb11e..d17e9633 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,35 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= +github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= +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.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +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.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +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/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -11,13 +41,37 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jacksonthemaster/discordrichpresence v1.1.0 h1:4UmompqAKyEpspR/Z0LFj6vdSWJf6FZQ/J787KvdxMM= github.com/jacksonthemaster/discordrichpresence v1.1.0/go.mod h1:XA0SB8bsEc5oJCQcXjC78BfNBj9FoLFA4ysfevkUjHE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +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/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/server.go b/server.go index 4a079f17..4bc51a82 100644 --- a/server.go +++ b/server.go @@ -49,10 +49,10 @@ func main() { wg.Wait() logger.Main.Debug("Initializing Backend...") loader.InitBackend() - logger.Main.Debug("Initializing after start tasks...") - loader.AfterStartComplete() logger.Main.Debug("Starting webserver...") web.StartWebServer(&wg) + logger.Main.Debug("Initializing after start tasks...") + loader.AfterStartComplete() logger.Main.Debug("Initializing SSUICLI...") cli.StartConsole(&wg) wg.Wait() diff --git a/src/cli/commands.go b/src/cli/commands.go index c00b426f..7c7b10ba 100644 --- a/src/cli/commands.go +++ b/src/cli/commands.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/cli/dashboard" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" @@ -23,6 +24,7 @@ import ( // init registers default cli commands and their aliases. func init() { RegisterCommand("help", helpCommand, "h") + RegisterCommand("dashboard", dashboardCommand, "dash", "d") RegisterCommand("reloadbackend", WrapNoReturn(loader.ReloadBackend), "rlb", "rb", "r") RegisterCommand("reloadconfig", WrapNoReturn(loader.ReloadConfig), "rlc", "rc") RegisterCommand("restartbackend", WrapNoReturn(loader.RestartBackend), "rsb") @@ -44,6 +46,16 @@ func init() { RegisterCommand("downloadworkshopitemtest", WrapNoReturn(downloadWorkshopItemTest), "dwmodcon") } +// dashboardCommand launches the interactive terminal dashboard +func dashboardCommand(args []string) error { + logger.Core.Info("Launching interactive dashboard... (press 'q' or 'esc' to exit)") + if err := dashboard.Run(); err != nil { + return fmt.Errorf("dashboard error: %w", err) + } + logger.Core.Info("Dashboard closed, returning to SSUICLI") + return nil +} + // COMMAND HANDLERS WITH COMMANDS USEFUL FOR USERS func downloadWorkshopUpdates() { diff --git a/src/cli/dashboard/config_panel.go b/src/cli/dashboard/config_panel.go new file mode 100644 index 00000000..a0993c50 --- /dev/null +++ b/src/cli/dashboard/config_panel.go @@ -0,0 +1,305 @@ +package dashboard + +import ( + "fmt" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader" + "github.com/charmbracelet/lipgloss" +) + +// Config Items Definition + +// buildConfigItems creates a list of editable config items grouped by section +func buildConfigItems() []ConfigItem { + return []ConfigItem{ + // ───────────────────────────────────────────────────────────────────── + // Basic Settings + // ───────────────────────────────────────────────────────────────────── + {Key: "ServerName", Label: "Server Name", Value: config.GetServerName(), Type: "string", Section: ConfigSectionBasic, Description: "Display name for your server"}, + {Key: "SaveName", Label: "Save Name", Value: config.GetSaveName(), Type: "string", Section: ConfigSectionBasic, Description: "World save file name"}, + {Key: "ServerMaxPlayers", Label: "Max Players", Value: config.GetServerMaxPlayers(), Type: "string", Section: ConfigSectionBasic, Description: "Maximum concurrent players"}, + {Key: "ServerPassword", Label: "Server Password", Value: config.GetServerPassword(), Type: "password", Section: ConfigSectionBasic, Description: "Password to join server (blank = none)"}, + {Key: "ServerVisible", Label: "Server Visible", Value: boolToStr(config.GetServerVisible()), Type: "bool", Section: ConfigSectionBasic, Description: "List server publicly"}, + {Key: "AutoSave", Label: "Auto-Save", Value: boolToStr(config.GetAutoSave()), Type: "bool", Section: ConfigSectionBasic, Description: "Enable automatic world saves"}, + {Key: "SaveInterval", Label: "Save Interval", Value: config.GetSaveInterval(), Type: "string", Section: ConfigSectionBasic, Description: "Time between saves (e.g., 300)"}, + + // ───────────────────────────────────────────────────────────────────── + // World Generation Settings + // ───────────────────────────────────────────────────────────────────── + {Key: "WorldID", Label: "World ID", Value: config.GetWorldID(), Type: "string", Section: ConfigSectionWorldGen, Description: "World seed identifier"}, + {Key: "Difficulty", Label: "Difficulty", Value: config.GetDifficulty(), Type: "string", Section: ConfigSectionWorldGen, Description: "Game difficulty setting"}, + {Key: "StartCondition", Label: "Start Condition", Value: config.GetStartCondition(), Type: "string", Section: ConfigSectionWorldGen, Description: "Initial spawn condition"}, + {Key: "StartLocation", Label: "Start Location", Value: config.GetStartLocation(), Type: "string", Section: ConfigSectionWorldGen, Description: "Starting world location"}, + + // ───────────────────────────────────────────────────────────────────── + // Network Settings + // ───────────────────────────────────────────────────────────────────── + {Key: "GamePort", Label: "Game Port", Value: config.GetGamePort(), Type: "string", Section: ConfigSectionNetwork, Description: "Main game connection port"}, + {Key: "UpdatePort", Label: "Update Port", Value: config.GetUpdatePort(), Type: "string", Section: ConfigSectionNetwork, Description: "Server query port"}, + {Key: "UPNPEnabled", Label: "UPnP Enabled", Value: boolToStr(config.GetUPNPEnabled()), Type: "bool", Section: ConfigSectionNetwork, Description: "Automatic port forwarding"}, + {Key: "LocalIpAddress", Label: "Local IP", Value: config.GetLocalIpAddress(), Type: "string", Section: ConfigSectionNetwork, Description: "Bind to specific IP (blank = auto)"}, + {Key: "StartLocalHost", Label: "Start LocalHost", Value: boolToStr(config.GetStartLocalHost()), Type: "bool", Section: ConfigSectionNetwork, Description: "Start in localhost mode"}, + {Key: "UseSteamP2P", Label: "Use Steam P2P", Value: boolToStr(config.GetUseSteamP2P()), Type: "bool", Section: ConfigSectionNetwork, Description: "Use Steam peer-to-peer networking"}, + + // ───────────────────────────────────────────────────────────────────── + // Advanced Settings + // ───────────────────────────────────────────────────────────────────── + {Key: "AutoStartServerOnStartup", Label: "Auto-Start Server", Value: boolToStr(config.GetAutoStartServerOnStartup()), Type: "bool", Section: ConfigSectionAdvanced, Description: "Start game server when SSUI starts"}, + {Key: "AutoRestartServerTimer", Label: "Auto-Restart Timer", Value: config.GetAutoRestartServerTimer(), Type: "string", Section: ConfigSectionAdvanced, Description: "Auto-restart interval (0 = disabled)"}, + {Key: "AutoPauseServer", Label: "Auto-Pause", Value: boolToStr(config.GetAutoPauseServer()), Type: "bool", Section: ConfigSectionAdvanced, Description: "Pause when no players"}, + {Key: "IsSSCMEnabled", Label: "SSCM/BepInEx", Value: boolToStr(config.GetIsSSCMEnabled()), Type: "bool", Section: ConfigSectionAdvanced, Description: "Enable mod support"}, + {Key: "Branch", Label: "Game Branch", Value: config.GetGameBranch(), Type: "string", Section: ConfigSectionAdvanced, Description: "Steam branch (public, beta, etc.)"}, + {Key: "LogClutterToConsole", Label: "Log Clutter", Value: boolToStr(config.GetLogClutterToConsole()), Type: "bool", Section: ConfigSectionAdvanced, Description: "Show verbose logs in console"}, + } +} + +// Config Saving + +// saveConfigItem saves a single config item using the appropriate setter +func saveConfigItem(item ConfigItem) error { + switch item.Key { + // Basic Settings + case "ServerName": + return config.SetServerName(item.Value) + case "SaveName": + return config.SetSaveName(item.Value) + case "ServerMaxPlayers": + return config.SetServerMaxPlayers(item.Value) + case "ServerPassword": + return config.SetServerPassword(item.Value) + case "ServerVisible": + return config.SetServerVisible(strToBool(item.Value)) + case "AutoSave": + return config.SetAutoSave(strToBool(item.Value)) + case "SaveInterval": + return config.SetSaveInterval(item.Value) + + // World Generation Settings + case "WorldID": + return config.SetWorldID(item.Value) + case "Difficulty": + return config.SetDifficulty(item.Value) + case "StartCondition": + return config.SetStartCondition(item.Value) + case "StartLocation": + return config.SetStartLocation(item.Value) + + // Network Settings + case "GamePort": + return config.SetGamePort(item.Value) + case "UpdatePort": + return config.SetUpdatePort(item.Value) + case "UPNPEnabled": + return config.SetUPNPEnabled(strToBool(item.Value)) + case "LocalIpAddress": + return config.SetLocalIpAddress(item.Value) + case "StartLocalHost": + return config.SetStartLocalHost(strToBool(item.Value)) + case "UseSteamP2P": + return config.SetUseSteamP2P(strToBool(item.Value)) + + // Advanced Settings + case "AutoStartServerOnStartup": + return config.SetAutoStartServerOnStartup(strToBool(item.Value)) + case "AutoRestartServerTimer": + return config.SetAutoRestartServerTimer(item.Value) + case "AutoPauseServer": + return config.SetAutoPauseServer(strToBool(item.Value)) + case "IsSSCMEnabled": + return config.SetIsSSCMEnabled(strToBool(item.Value)) + case "Branch": + return config.SetGameBranch(item.Value) + case "LogClutterToConsole": + return config.SetLogClutterToConsole(strToBool(item.Value)) + + } + return nil // Attention: saveConfigItem silently succeeds for unknown keys +} + +// saveAllConfigChanges saves all config items and reloads the backend +func saveAllConfigChanges(items []ConfigItem) error { + for _, item := range items { + if err := saveConfigItem(item); err != nil { + return fmt.Errorf("failed to save %s: %w", item.Key, err) + } + } + loader.ReloadBackend() + return nil +} + +// Panel Rendering + +func (m Model) renderConfigPanel() string { + var lines []string + + // Header with save status + headerLine := RenderSectionTitle("Configuration Editor") + if m.configHasChanges { + headerLine += " " + lipgloss.NewStyle().Foreground(Yellow).Bold(true).Render("• Unsaved Changes, Ctrl+S to Save") + } + if m.configStatusMsg != "" && m.configStatusTick > 0 { + headerLine += " " + lipgloss.NewStyle().Foreground(Green).Render(m.configStatusMsg) + } + lines = append(lines, headerLine) + lines = append(lines, "") + + lines = append(lines, MutedStyle.Render(" Use ↑↓ to navigate, Enter to edit, Space to toggle, Ctrl+S to save")) + lines = append(lines, "") + + navIndex := 0 + for section := range ConfigSectionCount { + sectionName := configSectionNames[section] + isOpen := m.configSectionOpen[section] + + expandIcon := "▶" + if isOpen { + expandIcon = "▼" + } + + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(PurpleLight) + sectionHeader := sectionStyle.Render(expandIcon + " " + sectionName) + + if !m.configEditing && m.configSelectedIndex == navIndex && m.activePanel == PanelConfig { + sectionHeader = lipgloss.NewStyle(). + Background(Purple). + Foreground(White). + Bold(true). + Render(expandIcon + " " + sectionName) + } + + lines = append(lines, sectionHeader) + navIndex++ + + if isOpen { + sectionItems := m.getItemsForSection(section) + for _, item := range sectionItems { + line := m.renderConfigItem(item, navIndex) + lines = append(lines, line) + navIndex++ + } + } + lines = append(lines, "") + } + + content := lipgloss.JoinVertical(lipgloss.Left, lines...) + return m.renderPanelContainer(content) +} + +func (m Model) renderConfigItem(item ConfigItem, index int) string { + isSelected := !m.configEditing && m.configSelectedIndex == index && m.activePanel == PanelConfig + isEditing := m.configEditing && m.configSelectedIndex == index && m.activePanel == PanelConfig + + labelStyle := lipgloss.NewStyle().Width(24).Foreground(Gray400) + label := labelStyle.Render(" " + item.Label + ":") + + var valueDisplay string + + if isEditing { + editStyle := lipgloss.NewStyle(). + Background(Gray700). + Foreground(White). + Padding(0, 1) + + displayValue := m.configEditValue + if len(displayValue) == 0 { + displayValue = " " + } + valueDisplay = editStyle.Render(displayValue + "▌") + } else if item.Type == "bool" { + if strToBool(item.Value) { + valueDisplay = lipgloss.NewStyle().Foreground(Green).Render(CheckMark + " true") + } else { + valueDisplay = lipgloss.NewStyle().Foreground(Red).Render(CrossMark + " false") + } + } else if item.Type == "password" && item.Value != "" { + valueDisplay = MutedStyle.Render("••••••••") + } else if item.Value == "" { + valueDisplay = MutedStyle.Render("(not set)") + } else { + valueDisplay = ValueStyle.Render(item.Value) + } + + if isSelected { + label = lipgloss.NewStyle(). + Width(24). + Background(Purple). + Foreground(White). + Render(" ▸ " + item.Label + ":") + } + + return label + " " + valueDisplay +} + +func (m Model) getItemsForSection(section ConfigSection) []ConfigItem { + var items []ConfigItem + for _, item := range m.configItems { + if item.Section == section { + items = append(items, item) + } + } + return items +} + +func (m Model) getTotalConfigItems() int { + count := 0 + for section := range ConfigSectionCount { + count++ // Section header + if m.configSectionOpen[section] { + count += len(m.getItemsForSection(section)) + } + } + return count +} + +// getConfigItemAtIndex returns a config item at given nav index +func (m Model) getConfigItemAtIndex(index int) (*ConfigItem, bool, ConfigSection) { + currentIndex := 0 + for section := range ConfigSectionCount { + // Check if we're on the section header + if currentIndex == index { + return nil, true, section // It's a section header + } + currentIndex++ + + if m.configSectionOpen[section] { + items := m.getItemsForSection(section) + for i := range items { + if currentIndex == index { + return &m.configItems[m.getGlobalItemIndex(section, i)], false, section + } + currentIndex++ + } + } + } + return nil, false, 0 +} + +// getGlobalItemIndex converts section-local index to global configItems index +func (m Model) getGlobalItemIndex(section ConfigSection, localIndex int) int { + globalIndex := 0 + for _, item := range m.configItems { + if item.Section == section { + if localIndex == 0 { + return globalIndex + } + localIndex-- + } + globalIndex++ + } + return -1 +} + +// Helpers + +func boolToStr(b bool) string { + if b { + return "true" + } + return "false" +} + +func strToBool(s string) bool { + return strings.ToLower(s) == "true" +} diff --git a/src/cli/dashboard/dashboard.go b/src/cli/dashboard/dashboard.go new file mode 100644 index 00000000..fa3dc846 --- /dev/null +++ b/src/cli/dashboard/dashboard.go @@ -0,0 +1,106 @@ +package dashboard + +import ( + "fmt" + "os" + "sync" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + tea "github.com/charmbracelet/bubbletea" + "github.com/mattn/go-isatty" +) + +func init() { + // Register hooks with the logger to enable log capture during dashboard mode. + // (logger doesn't import dashboard, dashboard imports logger). + // See note in logger/logger.go (bottom) + logger.RegisterDashboardHooks(IsDashboardActive, CaptureLog) +} + +var ( + // dashboardActive tracks whether the dashboard is currently running. + // Used by the logger to suppress console output during dashboard mode. + dashboardActive bool + dashboardMu sync.RWMutex + + // logBuffer holds SSUI log messages captured while dashboard is active. + logBuffer []string + logBufferMu sync.Mutex + maxLogLines = 200 +) + +// IsDashboardActive returns whether the dashboard is currently running. +// The logger can use this to suppress direct console writes. +func IsDashboardActive() bool { + dashboardMu.RLock() + defer dashboardMu.RUnlock() + return dashboardActive +} + +// setDashboardActive sets the dashboard active state. +func setDashboardActive(active bool) { + dashboardMu.Lock() + defer dashboardMu.Unlock() + dashboardActive = active +} + +// CaptureLog captures a Backend log line for display in the dashboard. +// Called by the logger when dashboard is active. +func CaptureLog(line string) { + logBufferMu.Lock() + defer logBufferMu.Unlock() + logBuffer = append(logBuffer, line) + if len(logBuffer) > maxLogLines { + logBuffer = logBuffer[1:] + } +} + +// GetLogBuffer returns a copy of the current Backend log buffer. (Dashboard-Local) +func GetLogBuffer() []string { + logBufferMu.Lock() + defer logBufferMu.Unlock() + result := make([]string, len(logBuffer)) + copy(result, logBuffer) + return result +} + +// ClearLogBuffer clears the Backend log buffer. (Dashboard-Local) +func ClearLogBuffer() { + logBufferMu.Lock() + defer logBufferMu.Unlock() + logBuffer = nil +} + +// IsInteractiveTerminal checks if stdin/stdout are connected to a terminal. +// Returns false for Docker, systemd, or piped/redirected IO. +func IsInteractiveTerminal() bool { + return isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd()) +} + +// Run takes over the terminal until the user exits. +func Run() error { + + setDashboardActive(true) + ClearLogBuffer() + + m := NewModel() + + p := tea.NewProgram( + m, + tea.WithAltScreen(), // Use alternate screen buffer (preserves shell history) + ) + + finalModel, err := p.Run() + + setDashboardActive(false) + + if err != nil { + return fmt.Errorf("dashboard error: %w", err) + } + + if m, ok := finalModel.(Model); ok && m.err != nil { + return m.err + } + + return nil +} diff --git a/src/cli/dashboard/model.go b/src/cli/dashboard/model.go new file mode 100644 index 00000000..cf390308 --- /dev/null +++ b/src/cli/dashboard/model.go @@ -0,0 +1,408 @@ +package dashboard + +import ( + "fmt" + "strconv" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +// Panel System + +type Panel int // holds current active panel +type ConfigSection int + +// ConfigItem represents a single editable config item +type ConfigItem struct { + Key string + Label string + Value string + Type string // "string", "bool", "int" + Section ConfigSection + Description string +} + +const ( + PanelStatus Panel = iota // Server status overview (default) + PanelSSUILog // SSUI backend logs + PanelPlayers // Connected players list + PanelConfig // Configuration editor + PanelCount // Used for cycling +) + +var panelNames = map[Panel]string{ + PanelStatus: "Status", + PanelSSUILog: "Logs", + PanelPlayers: "Players", + PanelConfig: "Config", +} + +var panelIcons = map[Panel]string{ + PanelStatus: "📊", + PanelSSUILog: "📜", + PanelPlayers: "👥", + PanelConfig: "⚙", +} + +const ( + ConfigSectionBasic ConfigSection = iota + ConfigSectionNetwork + ConfigSectionAdvanced + ConfigSectionWorldGen + ConfigSectionCount +) + +var configSectionNames = map[ConfigSection]string{ + ConfigSectionBasic: "Basic Settings", + ConfigSectionNetwork: "Network Settings", + ConfigSectionAdvanced: "Advanced Settings", + ConfigSectionWorldGen: "World Generation", +} + +// Model - Dashboard State + +// Model represents the dashboard state +type Model struct { + + // Window and Layout + + width int + height int + + // Navigation + + activePanel Panel + + // Components + + logViewport viewport.Model // For SSUI backend logs + help help.Model + keys keyMap + + // Server State + + serverRunning bool + serverUptime time.Duration + serverStartTime time.Time + serverName string + saveName string + worldID string + gamePort string + updatePort string + maxPlayers int + connectedPlayers map[string]string // SteamID -> PlayerName + + // Game Info + + gameVersion string + gameBranch string + buildID string + + // SSUI Info + + ssuiVersion string + goRuntime string + isDocker bool + + // Feature Flags + + discordEnabled bool + autoSaveEnabled bool + autoRestartEnabled bool + autoRestartTimer string + upnpEnabled bool + authEnabled bool + bepInExEnabled bool + saveInterval string + autoStartEnabled bool + + // Backup Info + + backupKeepLastN int + backupDailyFor int + backupWeeklyFor int + backupMonthlyFor int + + // Config Panel State + + configItems []ConfigItem // All config items + configSectionOpen map[ConfigSection]bool // Which sections are expanded + configSelectedIndex int // Currently selected config item + configEditing bool // Whether we're editing a value + configEditValue string // Current edit buffer + configCursorPos int // Cursor position in edit + configHasChanges bool // Unsaved changes flag + configStatusMsg string // Status message (e.g., "Saved!") + configStatusTick int // Countdown for status message + + // Internal State + + err error + quitting bool + startTime time.Time + tickCount int + lastRefresh time.Time + showFullHelp bool +} + +// Key Bindings + +type keyMap struct { + Tab key.Binding + ShiftTab key.Binding + Up key.Binding + Down key.Binding + PageUp key.Binding + PageDown key.Binding + Home key.Binding + End key.Binding + Start key.Binding + Stop key.Binding + Refresh key.Binding + Help key.Binding + Quit key.Binding + Enter key.Binding // For config editing + Space key.Binding // Toggle sections/bools + Save key.Binding // Save config changes +} + +// ShortHelp returns the short help text (displayed in footer) +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Tab, k.Start, k.Stop, k.Refresh, k.Help, k.Quit} +} + +// FullHelp returns the full help text (displayed when '?' is pressed) +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Tab, k.ShiftTab, k.Up, k.Down}, + {k.PageUp, k.PageDown, k.Home, k.End}, + {k.Start, k.Stop, k.Refresh}, + {k.Enter, k.Space, k.Save}, + {k.Help, k.Quit}, + } +} + +func defaultKeyMap() keyMap { + return keyMap{ + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next panel"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev panel"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up/scroll"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down/scroll"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "ctrl+u"), + key.WithHelp("pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", "ctrl+d"), + key.WithHelp("pgdn", "page down"), + ), + Home: key.NewBinding( + key.WithKeys("home", "g"), + key.WithHelp("home/g", "top"), + ), + End: key.NewBinding( + key.WithKeys("end", "G"), + key.WithHelp("end/G", "bottom"), + ), + Start: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "start server"), + ), + Stop: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "stop server"), + ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "toggle help"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "edit/confirm"), + ), + Space: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("space", "toggle"), + ), + Save: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "save config"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("q/esc", "exit"), + ), + } +} + +// Model Initialization + +// NewModel creates a new dashboard model with initial values +func NewModel() Model { + vp := viewport.New(80, 20) + vp.SetContent("Initializing log viewer...") + + h := help.New() + h.ShowAll = false + + // Initialize config sections as collapsed + sectionOpen := make(map[ConfigSection]bool) + for i := range ConfigSectionCount { + sectionOpen[i] = false + } + // Open Basic section by default + sectionOpen[ConfigSectionBasic] = true + + return Model{ + activePanel: PanelStatus, // Default + logViewport: vp, + help: h, + keys: defaultKeyMap(), + startTime: time.Now(), + lastRefresh: time.Now(), + connectedPlayers: make(map[string]string), + ssuiVersion: "...", + goRuntime: "...", + configSectionOpen: sectionOpen, + configItems: buildConfigItems(), // Populate config vals + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + tickCmd(), + tea.SetWindowTitle("SSUI Dashboard"), + ) +} + +// Messages + +// tickMsg is sent periodically to update the dashboard +type tickMsg time.Time + +// tickCmd returns a command that sends a tick message every second +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// statusUpdateMsg is sent when server status is updated +type statusUpdateMsg struct { + // Server state + running bool + uptime time.Duration + startTime time.Time + serverName string + saveName string + worldID string + gamePort string + updatePort string + maxPlayers int + players map[string]string + + // Game info + gameVersion string + gameBranch string + buildID string + + // SSUI info + version string + goRuntime string + isDocker bool + + // Features + discordEnabled bool + autoSaveEnabled bool + autoRestartEnabled bool + autoRestartTimer string + upnpEnabled bool + authEnabled bool + bepInExEnabled bool + saveInterval string + autoStartEnabled bool + + // Backup + backupKeepLastN int + backupDailyFor int + backupWeeklyFor int + backupMonthlyFor int +} + +// logUpdateMsg is sent when new SSUI logs are available +type logUpdateMsg struct { + logs []string +} + +// serverActionMsg is sent after a server action (start/stop) +type serverActionMsg struct { + action string + err error +} + +// Helpers + +// formatUptime formats duration to a human-readable string +func formatUptime(d time.Duration) string { + if d == 0 { + return "N/A" + } + d = d.Round(time.Second) + + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + mins := int(d.Minutes()) % 60 + secs := int(d.Seconds()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm %ds", hours, mins, secs) + } + if mins > 0 { + return fmt.Sprintf("%dm %ds", mins, secs) + } + return fmt.Sprintf("%ds", secs) +} + +// formatUptimeShort returns a shorter uptime format +func formatUptimeShort(d time.Duration) string { + if d == 0 { + return "--:--:--" + } + d = d.Round(time.Second) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} + +// parseMaxPlayers converts the string max players to int +func parseMaxPlayers(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n +} diff --git a/src/cli/dashboard/styles.go b/src/cli/dashboard/styles.go new file mode 100644 index 00000000..04a7c319 --- /dev/null +++ b/src/cli/dashboard/styles.go @@ -0,0 +1,638 @@ +package dashboard + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// SSUI Dashboard Style System +// A comprehensive styling module using lipgloss for a polished terminal UI + +// Color Palette - SSUI Brand Colors + +var ( + // Primary brand colors + Purple = lipgloss.Color("#7C3AED") + PurpleLight = lipgloss.Color("#A78BFA") + PurpleDark = lipgloss.Color("#5B21B6") + + // Semantic colors + Green = lipgloss.Color("#10B981") + GreenLight = lipgloss.Color("#34D399") + Red = lipgloss.Color("#EF4444") + RedLight = lipgloss.Color("#F87171") + Yellow = lipgloss.Color("#F59E0B") + YellowLight = lipgloss.Color("#FBBF24") + Blue = lipgloss.Color("#3B82F6") + BlueLight = lipgloss.Color("#60A5FA") + Cyan = lipgloss.Color("#06B6D4") + CyanLight = lipgloss.Color("#22D3EE") + + // Neutral colors + White = lipgloss.Color("#F9FAFB") + Gray100 = lipgloss.Color("#F3F4F6") + Gray200 = lipgloss.Color("#E5E7EB") + Gray300 = lipgloss.Color("#D1D5DB") + Gray400 = lipgloss.Color("#9CA3AF") + Gray500 = lipgloss.Color("#6B7280") + Gray600 = lipgloss.Color("#4B5563") + Gray700 = lipgloss.Color("#374151") + Gray800 = lipgloss.Color("#1F2937") + Gray900 = lipgloss.Color("#111827") + Black = lipgloss.Color("#030712") +) + +const ( + // Status indicators + BulletFilled = "●" + BulletEmpty = "○" + BulletHalf = "◐" + CheckMark = "✓" + CrossMark = "✗" + Lightning = "⚡" + Gear = "⚙" + Server = "󰒋" + Globe = "🌐" + Clock = "⏱" + Calendar = "📅" + Folder = "📁" + User = "👤" + Users = "👥" + Shield = "🛡" + Plug = "🔌" + Bot = "🤖" + Save = "💾" + Refresh = "🔄" + Play = "▶" + Stop = "■" + Pause = "⏸" + Rocket = "🚀" + Fire = "🔥" + Sparkle = "✨" + + // Box drawing characters + BoxTopLeft = "╭" + BoxTopRight = "╮" + BoxBottomLeft = "╰" + BoxBottomRight = "╯" + BoxHorizontal = "─" + BoxVertical = "│" + BoxCross = "┼" + BoxTeeRight = "├" + BoxTeeLeft = "┤" + BoxTeeDown = "┬" + BoxTeeUp = "┴" + + // Double line box + BoxDoubleH = "═" + BoxDoubleV = "║" + + // Progress bar characters + ProgressFull = "█" + ProgressHigh = "▓" + ProgressMid = "▒" + ProgressLow = "░" + ProgressEmpty = "░" + + // Sparkline characters (for mini graphs) + Spark1 = "▁" + Spark2 = "▂" + Spark3 = "▃" + Spark4 = "▄" + Spark5 = "▅" + Spark6 = "▆" + Spark7 = "▇" + Spark8 = "█" + + // Arrows and pointers + ArrowRight = "→" + ArrowLeft = "←" + ArrowUp = "↑" + ArrowDown = "↓" + Pointer = "▸" + Diamond = "◆" + Triangle = "▲" +) + +// Spinner frames for animated loading indicators +var SpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +var SpinnerDots = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} +var SpinnerPulse = []string{"●", "◐", "○", "◑"} + +// Base Styles + +var ( + // Base text styles + BaseStyle = lipgloss.NewStyle(). + Foreground(White) + + BoldStyle = lipgloss.NewStyle(). + Foreground(White). + Bold(true) + + MutedStyle = lipgloss.NewStyle(). + Foreground(Gray500) + + DimStyle = lipgloss.NewStyle(). + Foreground(Gray600) +) + +// Logo and Header Styles + +var ( + // ASCII art logo style with gradient effect + LogoStyle = lipgloss.NewStyle(). + Foreground(Purple). + Bold(true) + + LogoAccentStyle = lipgloss.NewStyle(). + Foreground(PurpleLight) + + // Main header container + HeaderContainerStyle = lipgloss.NewStyle(). + Background(Gray800). + Padding(0, 2) + + // Title bar style + TitleStyle = lipgloss.NewStyle(). + Foreground(White). + Background(Purple). + Bold(true). + Padding(0, 2). + MarginRight(1) + + // Version badge + VersionBadgeStyle = lipgloss.NewStyle(). + Foreground(PurpleLight). + Background(Gray700). + Padding(0, 1) + + // Status pill styles (for header indicators) + StatusPillOnline = lipgloss.NewStyle(). + Foreground(Gray900). + Background(Green). + Bold(true). + Padding(0, 1) + + StatusPillOffline = lipgloss.NewStyle(). + Foreground(White). + Background(Red). + Bold(true). + Padding(0, 1) + + StatusPillWarning = lipgloss.NewStyle(). + Foreground(Gray900). + Background(Yellow). + Bold(true). + Padding(0, 1) +) + +// Tab Bar Styles + +var ( + TabBarStyle = lipgloss.NewStyle(). + Background(Gray800). + Padding(0, 1) + + TabActiveStyle = lipgloss.NewStyle(). + Foreground(White). + Background(Purple). + Bold(true). + Padding(0, 2) + + TabInactiveStyle = lipgloss.NewStyle(). + Foreground(Gray400). + Background(Gray700). + Padding(0, 2) + + TabSeparatorStyle = lipgloss.NewStyle(). + Foreground(Gray600) +) + +// Panel Styles + +var ( + // Main panel container with rounded border + PanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Purple). + Padding(1, 2) + + // Panel variants + PanelActiveStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PurpleLight). + Padding(1, 2) + + PanelDimStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Gray600). + Padding(1, 2) + + // Section header within panels + SectionHeaderStyle = lipgloss.NewStyle(). + Foreground(PurpleLight). + Bold(true). + MarginBottom(1) + + // Divider line + DividerStyle = lipgloss.NewStyle(). + Foreground(Gray600) +) + +// Status Indicator Styles + +var ( + // Online/Offline indicators + OnlineStyle = lipgloss.NewStyle(). + Foreground(Green). + Bold(true) + + OfflineStyle = lipgloss.NewStyle(). + Foreground(Red). + Bold(true) + + WarningStyle = lipgloss.NewStyle(). + Foreground(Yellow). + Bold(true) + + InfoStyle = lipgloss.NewStyle(). + Foreground(Blue) + + // Feature status badges + FeatureEnabledStyle = lipgloss.NewStyle(). + Foreground(Gray900). + Background(Green). + Padding(0, 1) + + FeatureDisabledStyle = lipgloss.NewStyle(). + Foreground(Gray400). + Background(Gray700). + Padding(0, 1) + + FeatureActiveStyle = lipgloss.NewStyle(). + Foreground(Gray900). + Background(Cyan). + Padding(0, 1) +) + +// Data Display Styles + +var ( + // Key-value pair styles + LabelStyle = lipgloss.NewStyle(). + Foreground(Gray400). + Width(20) + + ValueStyle = lipgloss.NewStyle(). + Foreground(White) + + ValueHighlightStyle = lipgloss.NewStyle(). + Foreground(PurpleLight). + Bold(true) + + ValueSuccessStyle = lipgloss.NewStyle(). + Foreground(Green) + + ValueErrorStyle = lipgloss.NewStyle(). + Foreground(Red) + + ValueWarningStyle = lipgloss.NewStyle(). + Foreground(Yellow) + + // Numeric value style + NumberStyle = lipgloss.NewStyle(). + Foreground(Cyan) + + // ID/Code style (monospace look) + CodeStyle = lipgloss.NewStyle(). + Foreground(Gray300). + Italic(true) +) + +// Player List Styles + +var ( + PlayerRowStyle = lipgloss.NewStyle(). + Padding(0, 1) + + PlayerIndexStyle = lipgloss.NewStyle(). + Foreground(Gray500). + Width(4) + + PlayerNameStyle = lipgloss.NewStyle(). + Foreground(GreenLight). + Bold(true) + + PlayerIDStyle = lipgloss.NewStyle(). + Foreground(Gray500). + Italic(true) + + PlayerEmptyStyle = lipgloss.NewStyle(). + Foreground(Gray500). + Italic(true) +) + +// Log Viewer Styles + +var ( + LogContainerStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Gray600). + Padding(0, 1) + + LogTimestampStyle = lipgloss.NewStyle(). + Foreground(Gray500) + + LogLevelDebugStyle = lipgloss.NewStyle(). + Foreground(Gray400) + + LogLevelInfoStyle = lipgloss.NewStyle(). + Foreground(Blue) + + LogLevelWarnStyle = lipgloss.NewStyle(). + Foreground(Yellow) + + LogLevelErrorStyle = lipgloss.NewStyle(). + Foreground(Red). + Bold(true) + + LogMessageStyle = lipgloss.NewStyle(). + Foreground(Gray300) + + LogScrollIndicatorStyle = lipgloss.NewStyle(). + Foreground(Gray500). + Italic(true) +) + +// Footer Styles + +var ( + FooterStyle = lipgloss.NewStyle(). + Background(Gray800). + Foreground(Gray400). + Padding(0, 2) + + FooterBrandStyle = lipgloss.NewStyle(). + Foreground(Purple). + Bold(true) + + FooterStatStyle = lipgloss.NewStyle(). + Foreground(Gray400) + + FooterSeparatorStyle = lipgloss.NewStyle(). + Foreground(Gray600) + + // Key hint styles + KeyStyle = lipgloss.NewStyle(). + Foreground(Gray300). + Background(Gray700). + Padding(0, 1) + + KeyDescStyle = lipgloss.NewStyle(). + Foreground(Gray500) +) + +// Help Styles + +var ( + HelpStyle = lipgloss.NewStyle(). + Foreground(Gray500) + + HelpKeyStyle = lipgloss.NewStyle(). + Foreground(Gray300) + + HelpDescStyle = lipgloss.NewStyle(). + Foreground(Gray500) + + HelpSeparatorStyle = lipgloss.NewStyle(). + Foreground(Gray600) +) + +// Utility Functions + +// RenderProgressBar creates a visual progress bar +func RenderProgressBar(current, max, width int) string { + if max == 0 { + max = 1 + } + if width < 5 { + width = 10 + } + + ratio := float64(current) / float64(max) + filled := min(int(ratio*float64(width)), width) + + bar := "" + for i := 0; i < width; i++ { + if i < filled { + bar += ProgressFull + } else { + bar += ProgressEmpty + } + } + + // Color based on fill level + var style lipgloss.Style + switch { + case ratio >= 0.9: + style = lipgloss.NewStyle().Foreground(Red) + case ratio >= 0.7: + style = lipgloss.NewStyle().Foreground(Yellow) + default: + style = lipgloss.NewStyle().Foreground(Green) + } + + return style.Render(bar) +} + +// RenderStatusDot returns a colored status dot +func RenderStatusDot(online bool) string { + if online { + return OnlineStyle.Render(BulletFilled) + } + return OfflineStyle.Render(BulletEmpty) +} + +// RenderFeatureBadge creates a badge for feature status +func RenderFeatureBadge(name string, enabled bool) string { + if enabled { + return FeatureEnabledStyle.Render(CheckMark + " " + name) + } + return FeatureDisabledStyle.Render(CrossMark + " " + name) +} + +// RenderKeyValue renders a styled key-value pair +func RenderKeyValue(key, value string) string { + return LabelStyle.Render(key+":") + " " + ValueStyle.Render(value) +} + +// RenderKeyValueHighlight renders a key-value pair with highlighted value +func RenderKeyValueHighlight(key, value string) string { + return LabelStyle.Render(key+":") + " " + ValueHighlightStyle.Render(value) +} + +// RenderDivider creates a horizontal divider line +func RenderDivider(width int) string { + if width < 1 { + width = 40 + } + var line strings.Builder + for i := 0; i < width; i++ { + line.WriteString(BoxHorizontal) + } + return DividerStyle.Render(line.String()) +} + +// RenderSectionTitle creates a styled section title +func RenderSectionTitle(title string) string { + return SectionHeaderStyle.Render(Diamond + " " + title) +} + +// Gradient applies a simple two-color gradient to text (line by line) +func Gradient(text string, from, to lipgloss.Color) string { + // Simple implementation: alternate colors by line + lines := splitLines(text) + result := "" + for i, line := range lines { + var style lipgloss.Style + if i%2 == 0 { + style = lipgloss.NewStyle().Foreground(from) + } else { + style = lipgloss.NewStyle().Foreground(to) + } + result += style.Render(line) + if i < len(lines)-1 { + result += "\n" + } + } + return result +} + +// splitLines splits text into lines +func splitLines(text string) []string { + var lines []string + current := "" + for _, r := range text { + if r == '\n' { + lines = append(lines, current) + current = "" + } else { + current += string(r) + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +// GetSpinnerFrame returns the current spinner frame based on tick count +func GetSpinnerFrame(tickCount int) string { + return SpinnerFrames[tickCount%len(SpinnerFrames)] +} + +// GetSpinnerDot returns the current dot spinner frame based on tick count +func GetSpinnerDot(tickCount int) string { + return SpinnerDots[tickCount%len(SpinnerDots)] +} + +// GetPulse returns the current pulse frame based on tick count +func GetPulse(tickCount int) string { + return SpinnerPulse[tickCount%len(SpinnerPulse)] +} + +// RenderSparkline creates a mini sparkline from values +func RenderSparkline(values []int, maxVal int) string { + sparkChars := []string{Spark1, Spark2, Spark3, Spark4, Spark5, Spark6, Spark7, Spark8} + if maxVal <= 0 { + maxVal = 1 + for _, v := range values { + if v > maxVal { + maxVal = v + } + } + } + + result := "" + for _, v := range values { + idx := max(int(float64(v)/float64(maxVal)*float64(len(sparkChars)-1)), 0) + if idx >= len(sparkChars) { + idx = len(sparkChars) - 1 + } + result += sparkChars[idx] + } + return result +} + +// RenderMiniBar creates a compact horizontal bar +func RenderMiniBar(value, max int, width int) string { + if max <= 0 { + max = 1 + } + if width <= 0 { + width = 10 + } + + filled := int(float64(value) / float64(max) * float64(width)) + if filled > width { + filled = width + } + + result := "" + for i := 0; i < width; i++ { + if i < filled { + result += "▰" + } else { + result += "▱" + } + } + return result +} + +// RenderAnimatedDots creates animated dots based on tick count +func RenderAnimatedDots(tickCount int) string { + dots := []string{"", ".", "..", "..."} + return dots[tickCount%len(dots)] +} + +// RenderBoxedText creates text surrounded by a box +func RenderBoxedText(text string, color lipgloss.Color) string { + width := len(text) + 2 + top := BoxTopLeft + repeat(BoxHorizontal, width) + BoxTopRight + bottom := BoxBottomLeft + repeat(BoxHorizontal, width) + BoxBottomRight + middle := BoxVertical + " " + text + " " + BoxVertical + + style := lipgloss.NewStyle().Foreground(color) + return style.Render(top + "\n" + middle + "\n" + bottom) +} + +// repeat repeats a string n times +func repeat(s string, n int) string { + result := "" + for range n { + result += s + } + return result +} + +// RenderStatusIndicator creates an animated status indicator +func RenderStatusIndicator(online bool, tickCount int) string { + if online { + // Pulsing green dot when online + pulse := GetPulse(tickCount) + return lipgloss.NewStyle().Foreground(Green).Bold(true).Render(pulse) + } + // Static empty circle when offline + return lipgloss.NewStyle().Foreground(Red).Render(BulletEmpty) +} + +// RenderActivityIndicator shows activity with animated spinner +func RenderActivityIndicator(active bool, tickCount int, label string) string { + if active { + spinner := GetSpinnerFrame(tickCount) + return lipgloss.NewStyle().Foreground(Cyan).Render(spinner) + " " + label + } + return MutedStyle.Render("○ " + label) +} diff --git a/src/cli/dashboard/update.go b/src/cli/dashboard/update.go new file mode 100644 index 00000000..cb2c9c3d --- /dev/null +++ b/src/cli/dashboard/update.go @@ -0,0 +1,402 @@ +package dashboard + +import ( + "runtime" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +// Update implements tea.Model - handles all messages and key presses +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + // Handle key presses + + // When editing config, ONLY handle editing-related keys + if m.configEditing && m.activePanel == PanelConfig { + switch msg.Type { + case tea.KeyEnter: + // Confirm edit - save the new value + item, isHeader, _ := m.getConfigItemAtIndex(m.configSelectedIndex) + if item != nil && !isHeader { + for i := range m.configItems { + if m.configItems[i].Key == item.Key { + m.configItems[i].Value = m.configEditValue + break + } + } + m.configHasChanges = true + } + m.configEditing = false + m.configEditValue = "" + case tea.KeyEscape: + // Cancel editing + m.configEditing = false + m.configEditValue = "" + case tea.KeyBackspace: + if len(m.configEditValue) > 0 { + m.configEditValue = m.configEditValue[:len(m.configEditValue)-1] + } + case tea.KeySpace: + // Insert a space character when editing + m.configEditValue += " " + case tea.KeyRunes: + m.configEditValue += string(msg.Runes) + } + return m, nil // Don't process other keybinds while editing + } + + switch { + case key.Matches(msg, m.keys.Quit): + m.quitting = true + return m, tea.Quit + + case key.Matches(msg, m.keys.Tab): + // Cycle forward through panels + m.activePanel = (m.activePanel + 1) % PanelCount + + case key.Matches(msg, m.keys.ShiftTab): + // Cycle backward through panels + m.activePanel = (m.activePanel - 1 + PanelCount) % PanelCount + + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + m.showFullHelp = m.help.ShowAll + + case key.Matches(msg, m.keys.Up): + if m.activePanel == PanelSSUILog { + m.logViewport.LineUp(3) + } else if m.activePanel == PanelConfig && !m.configEditing { + if m.configSelectedIndex > 0 { + m.configSelectedIndex-- + } + } + + case key.Matches(msg, m.keys.Down): + if m.activePanel == PanelSSUILog { + m.logViewport.LineDown(3) + } else if m.activePanel == PanelConfig && !m.configEditing { + if m.configSelectedIndex < m.getTotalConfigItems()-1 { + m.configSelectedIndex++ + } + } + + case key.Matches(msg, m.keys.PageUp): + if m.activePanel == PanelSSUILog { + m.logViewport.HalfPageUp() + } + + case key.Matches(msg, m.keys.PageDown): + if m.activePanel == PanelSSUILog { + m.logViewport.HalfPageDown() + } + + case key.Matches(msg, m.keys.Home): + if m.activePanel == PanelSSUILog { + m.logViewport.GotoTop() + } + + case key.Matches(msg, m.keys.End): + if m.activePanel == PanelSSUILog { + m.logViewport.GotoBottom() + } + + case key.Matches(msg, m.keys.Start): + if !m.serverRunning { + return m, startServerCmd() + } + + case key.Matches(msg, m.keys.Stop): + if m.serverRunning { + return m, stopServerCmd() + } + + case key.Matches(msg, m.keys.Refresh): + // Force refresh of all data + return m, tea.Batch(fetchStatusCmd(), fetchLogsCmd()) + + case key.Matches(msg, m.keys.Enter): + if m.activePanel == PanelConfig { + if m.configEditing { + // Confirm edit - save the new value + item, isHeader, _ := m.getConfigItemAtIndex(m.configSelectedIndex) + if item != nil && !isHeader { + // Update the item value + for i := range m.configItems { + if m.configItems[i].Key == item.Key { + m.configItems[i].Value = m.configEditValue + break + } + } + m.configHasChanges = true + } + m.configEditing = false + m.configEditValue = "" + } else { + // Start editing + item, isHeader, section := m.getConfigItemAtIndex(m.configSelectedIndex) + if isHeader { + // Toggle section open/closed + m.configSectionOpen[section] = !m.configSectionOpen[section] + } else if item != nil { + if item.Type == "bool" { + // Toggle bool directly + if item.Value == "true" { + item.Value = "false" + } else { + item.Value = "true" + } + // Update in configItems + for i := range m.configItems { + if m.configItems[i].Key == item.Key { + m.configItems[i].Value = item.Value + break + } + } + m.configHasChanges = true + } else { + // Start text editing + m.configEditing = true + m.configEditValue = item.Value + } + } + } + } + + case key.Matches(msg, m.keys.Space): + if m.activePanel == PanelConfig && !m.configEditing { + item, isHeader, section := m.getConfigItemAtIndex(m.configSelectedIndex) + if isHeader { + // Toggle section open/closed + m.configSectionOpen[section] = !m.configSectionOpen[section] + } else if item != nil && item.Type == "bool" { + // Toggle bool value + newVal := "true" + if item.Value == "true" { + newVal = "false" + } + for i := range m.configItems { + if m.configItems[i].Key == item.Key { + m.configItems[i].Value = newVal + break + } + } + m.configHasChanges = true + } + } + + case key.Matches(msg, m.keys.Save): + if m.activePanel == PanelConfig && m.configHasChanges && !m.configEditing { + // Save all config changes + err := saveAllConfigChanges(m.configItems) + if err != nil { + m.configStatusMsg = "✗ " + err.Error() + } else { + m.configStatusMsg = "✓ Saved & reloaded" + m.configHasChanges = false + // Reload config items to reflect any changes + m.configItems = buildConfigItems() + } + m.configStatusTick = 30 // Show for ~3 seconds + } + } + + case tea.WindowSizeMsg: + // Window was resized + m.width = msg.Width + m.height = msg.Height + + // Calculate content area height + headerHeight := 6 // Status bar + tabs + footerHeight := 2 // Footer + panelPadding := 6 // Border + padding + + contentHeight := m.height - headerHeight - footerHeight - panelPadding + if contentHeight < 5 { + contentHeight = 5 + } + contentWidth := m.width - 8 // Account for panel borders and padding + if contentWidth < 20 { + contentWidth = 20 + } + + // Update viewport + m.logViewport.Width = contentWidth + m.logViewport.Height = contentHeight + m.help.Width = m.width + + case tickMsg: + // Periodic update - refresh data every second + m.tickCount++ + cmds = append(cmds, tickCmd()) // Schedule next tick + cmds = append(cmds, fetchStatusCmd()) + cmds = append(cmds, fetchLogsCmd()) + + // Decrement config status message timer + if m.configStatusTick > 0 { + m.configStatusTick-- + if m.configStatusTick == 0 { + m.configStatusMsg = "" + } + } + + case statusUpdateMsg: + // Update all status fields from fetched data + m.serverRunning = msg.running + m.serverUptime = msg.uptime + m.serverStartTime = msg.startTime + m.serverName = msg.serverName + m.saveName = msg.saveName + m.worldID = msg.worldID + m.gamePort = msg.gamePort + m.updatePort = msg.updatePort + m.maxPlayers = msg.maxPlayers + m.connectedPlayers = msg.players + + // Game info + m.gameVersion = msg.gameVersion + m.gameBranch = msg.gameBranch + m.buildID = msg.buildID + + // SSUI info + m.ssuiVersion = msg.version + m.goRuntime = msg.goRuntime + m.isDocker = msg.isDocker + + // Features + m.discordEnabled = msg.discordEnabled + m.autoSaveEnabled = msg.autoSaveEnabled + m.autoRestartEnabled = msg.autoRestartEnabled + m.autoRestartTimer = msg.autoRestartTimer + m.upnpEnabled = msg.upnpEnabled + m.authEnabled = msg.authEnabled + m.bepInExEnabled = msg.bepInExEnabled + m.saveInterval = msg.saveInterval + m.autoStartEnabled = msg.autoStartEnabled + + // Backup info + m.backupKeepLastN = msg.backupKeepLastN + m.backupDailyFor = msg.backupDailyFor + m.backupWeeklyFor = msg.backupWeeklyFor + m.backupMonthlyFor = msg.backupMonthlyFor + + m.lastRefresh = msg.startTime + + case logUpdateMsg: + // Update log viewport content + if len(msg.logs) > 0 { + content := strings.Join(msg.logs, "") + m.logViewport.SetContent(content) + m.logViewport.GotoBottom() + } + + case serverActionMsg: + // Handle server action results + if msg.err != nil { + m.err = msg.err + } + // Refresh status after action + cmds = append(cmds, fetchStatusCmd()) + } + + return m, tea.Batch(cmds...) +} + +// fetchStatusCmd returns a command that fetches current server status from real sources +func fetchStatusCmd() tea.Cmd { + return func() tea.Msg { + // Get real data from config and managers + running := config.GetIsGameServerRunning() + uptime := gamemgr.GetServerUptime() + startTime := gamemgr.GetServerStartTime() + + // Get connected players + detector := detectionmgr.GetDetector() + players := detectionmgr.GetPlayers(detector) + + // Parse auto restart timer - check if it's enabled (not empty or "0") + autoRestartTimer := config.GetAutoRestartServerTimer() + autoRestartEnabled := autoRestartTimer != "" && autoRestartTimer != "0" + + return statusUpdateMsg{ + // Server state + running: running, + uptime: uptime, + startTime: startTime, + serverName: config.GetServerName(), + saveName: config.GetSaveName(), + worldID: config.GetWorldID(), + gamePort: config.GetGamePort(), + updatePort: config.GetUpdatePort(), + maxPlayers: parseMaxPlayers(config.GetServerMaxPlayers()), + players: players, + + // Game info + gameVersion: config.GetExtractedGameVersion(), + gameBranch: config.GetBranch(), + buildID: config.GetCurrentBranchBuildID(), + + // SSUI info + version: config.GetVersion(), + goRuntime: runtime.GOOS + "/" + runtime.GOARCH, + isDocker: config.GetIsDockerContainer(), + + // Features + discordEnabled: config.GetIsDiscordEnabled(), + autoSaveEnabled: config.GetAutoSave(), + autoRestartEnabled: autoRestartEnabled, + autoRestartTimer: autoRestartTimer, + upnpEnabled: config.GetUPNPEnabled(), + authEnabled: config.GetAuthEnabled(), + bepInExEnabled: config.GetIsSSCMEnabled(), // SSCM is the BepInEx integration + saveInterval: config.GetSaveInterval(), + autoStartEnabled: config.GetAutoStartServerOnStartup(), + + // Backup + backupKeepLastN: config.GetBackupKeepLastN(), + backupDailyFor: int(config.GetBackupKeepDailyFor().Hours() / 24), // Convert duration to days + backupWeeklyFor: int(config.GetBackupKeepWeeklyFor().Hours() / 168), // Convert to weeks + backupMonthlyFor: int(config.GetBackupKeepMonthlyFor().Hours() / 720), // Convert to months (approx) + } + } +} + +// fetchLogsCmd returns a command that fetches SSUI log entries +func fetchLogsCmd() tea.Cmd { + return func() tea.Msg { + logs := GetLogBuffer() + + if len(logs) == 0 { + logs = []string{ + "Waiting for log entries...\n", + "\n", + "Backend activity will appear here.\n", + } + } + + return logUpdateMsg{logs: logs} + } +} + +// startServerCmd starts the game server +func startServerCmd() tea.Cmd { + return func() tea.Msg { + err := gamemgr.InternalStartServer() + return serverActionMsg{action: "start", err: err} + } +} + +// stopServerCmd stops the game server +func stopServerCmd() tea.Cmd { + return func() tea.Msg { + err := gamemgr.InternalStopServer() + return serverActionMsg{action: "stop", err: err} + } +} diff --git a/src/cli/dashboard/view.go b/src/cli/dashboard/view.go new file mode 100644 index 00000000..ba5c0369 --- /dev/null +++ b/src/cli/dashboard/view.go @@ -0,0 +1,514 @@ +package dashboard + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// SSUI ASCII Art Logo + +const ssuiLogo = `███████╗███████╗██╗ ██╗██╗ +██╔════╝██╔════╝██║ ██║██║ +███████╗███████╗██║ ██║██║ +╚════██║╚════██║██║ ██║██║ +███████║███████║╚██████╔╝██║ +╚══════╝╚══════╝ ╚═════╝ ╚═╝` + +// Main View + +// View implements tea.Model - renders the entire UI +func (m Model) View() string { + if m.quitting { + farewell := lipgloss.NewStyle(). + Foreground(Purple). + Bold(true). + Render("\n 👋 Dashboard closed. Returning to CLI...\n\n") + return farewell + } + + // Build the UI + var b strings.Builder + + // Header with logo and status bar + b.WriteString(m.renderHeader()) + b.WriteString("\n") + + // Tab bar + b.WriteString(m.renderTabBar()) + b.WriteString("\n\n") + + // Main content panel + switch m.activePanel { + case PanelStatus: + b.WriteString(m.renderStatusPanel()) + case PanelSSUILog: + b.WriteString(m.renderLogPanel()) + case PanelPlayers: + b.WriteString(m.renderPlayersPanel()) + case PanelConfig: + b.WriteString(m.renderConfigPanel()) + } + + b.WriteString("\n") + + // Footer with all controls + b.WriteString(m.renderFooter()) + + return b.String() +} + +// Header Components + +// renderHeader renders the header with status indicators +func (m Model) renderHeader() string { + // Status indicators + var statusItems []string + + // Server status pill + if m.serverRunning { + statusItems = append(statusItems, StatusPillOnline.Render(" "+BulletFilled+" ONLINE ")) + } else { + statusItems = append(statusItems, StatusPillOffline.Render(" "+BulletEmpty+" OFFLINE ")) + } + + // Player count with mini progress bar + playerCount := len(m.connectedPlayers) + playerBar := RenderMiniBar(playerCount, m.maxPlayers, 6) + playerText := fmt.Sprintf("%d/%d", playerCount, m.maxPlayers) + if playerCount > 0 { + playerStyle := lipgloss.NewStyle().Foreground(Cyan).Bold(true) + statusItems = append(statusItems, playerStyle.Render("👥 "+playerBar+" "+playerText)) + } else { + statusItems = append(statusItems, MutedStyle.Render("👥 "+playerBar+" "+playerText)) + } + + // Uptime (if running) + if m.serverRunning && m.serverUptime > 0 { + uptimeStyle := lipgloss.NewStyle().Foreground(Green) + statusItems = append(statusItems, uptimeStyle.Render("⏱ "+formatUptimeShort(m.serverUptime))) + } + + // Version badge + statusItems = append(statusItems, VersionBadgeStyle.Render("v"+m.ssuiVersion)) + + statusBar := lipgloss.JoinHorizontal(lipgloss.Center, strings.Join(statusItems, " ")) + + // Simple centered status bar + return lipgloss.NewStyle(). + Width(m.width). + Align(lipgloss.Center). + Render(statusBar) +} + +// renderTabBar renders the navigation tab bar +func (m Model) renderTabBar() string { + var tabs []string + + for i := Panel(0); i < PanelCount; i++ { + icon := panelIcons[i] + name := panelNames[i] + label := icon + " " + name + + if i == m.activePanel { + tabs = append(tabs, TabActiveStyle.Render(label)) + } else { + tabs = append(tabs, TabInactiveStyle.Render(label)) + } + } + + tabBar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + + // Add subtle divider line under tabs + dividerWidth := lipgloss.Width(tabBar) + if m.width > dividerWidth { + dividerWidth = m.width - 4 + } + divider := DividerStyle.Render(strings.Repeat("─", dividerWidth)) + + return lipgloss.JoinVertical(lipgloss.Left, tabBar, divider) +} + +// Status Panel + +func (m Model) renderStatusPanel() string { + // Left column: Server info + leftCol := m.renderServerInfoBox() + + // Right column: World info + Quick stats + rightCol := lipgloss.JoinVertical(lipgloss.Left, + m.renderWorldInfoBox(), + "", + m.renderQuickStatsBox(), + ) + + // Combine columns + leftWidth := m.width/2 - 4 + rightWidth := m.width/2 - 4 + if leftWidth < 30 { + leftWidth = 30 + } + if rightWidth < 30 { + rightWidth = 30 + } + + leftColStyled := lipgloss.NewStyle().Width(leftWidth).Render(leftCol) + rightColStyled := lipgloss.NewStyle().Width(rightWidth).Render(rightCol) + + content := lipgloss.JoinHorizontal(lipgloss.Top, leftColStyled, " ", rightColStyled) + + return m.renderPanelContainer(content) +} + +// renderLogoBlock renders the SSUI ASCII logo with gradient +func (m Model) renderLogoBlock() string { + logoLines := strings.Split(ssuiLogo, "\n") + var styledLogo strings.Builder + + // Apply gradient colors to logo lines + gradientColors := []lipgloss.Color{PurpleLight, Purple, Purple, PurpleDark, PurpleDark, Gray500} + for i, line := range logoLines { + color := gradientColors[i%len(gradientColors)] + styledLogo.WriteString(lipgloss.NewStyle().Foreground(color).Bold(true).Render(line)) + if i < len(logoLines)-1 { + styledLogo.WriteString("\n") + } + } + return styledLogo.String() +} + +func (m Model) renderServerInfoBox() string { + var lines []string + + lines = append(lines, RenderSectionTitle("Server Status")) + lines = append(lines, "") + + // Status line (simple, no animation) + var statusLine string + if m.serverRunning { + statusLine = lipgloss.NewStyle().Foreground(Green).Bold(true).Render(BulletFilled+" Online") + + MutedStyle.Render(" • ") + + lipgloss.NewStyle().Foreground(Cyan).Render("⏱ "+formatUptime(m.serverUptime)) + } else { + statusLine = OfflineStyle.Render(BulletEmpty+" Offline") + + MutedStyle.Render(" • press ") + + KeyStyle.Render("s") + + MutedStyle.Render(" to start") + } + lines = append(lines, statusLine) + lines = append(lines, "") + + // Server details with icons + lines = append(lines, " "+lipgloss.NewStyle().Foreground(PurpleLight).Render("🏷")+" "+RenderKeyValue("Server Name", m.serverName)) + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Cyan).Render("🌐")+" "+RenderKeyValue("Game Port", m.gamePort)) + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Blue).Render("📡")+" "+RenderKeyValue("Update Port", m.updatePort)) + lines = append(lines, "") + + // Player capacity with fancy progress bar + playerCount := len(m.connectedPlayers) + barWidth := 20 + playerBar := RenderProgressBar(playerCount, m.maxPlayers, barWidth) + playerText := fmt.Sprintf("%d/%d", playerCount, m.maxPlayers) + + // Add player icon and styling + playerIcon := "👥" + if playerCount == 0 { + playerIcon = MutedStyle.Render("👥") + } + + lines = append(lines, " "+playerIcon+" "+LabelStyle.Render("Players:")) + lines = append(lines, " "+playerBar+" "+NumberStyle.Render(playerText)) + lines = append(lines, "") + + // Add SSUI Logo underneath player count + lines = append(lines, m.renderLogoBlock()) + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m Model) renderWorldInfoBox() string { + var lines []string + + lines = append(lines, RenderSectionTitle("World Info")) + lines = append(lines, "") + + // World details with icons + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Green).Render("💾")+" "+RenderKeyValue("Save Name", m.saveName)) + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Cyan).Render("🔑")+" "+RenderKeyValue("World ID", m.worldID)) + lines = append(lines, "") + + // Game version with special formatting + versionDisplay := m.gameVersion + if versionDisplay == "" { + versionDisplay = MutedStyle.Render("Unknown") + } else { + versionDisplay = ValueHighlightStyle.Render(versionDisplay) + } + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Yellow).Render("📦")+" "+LabelStyle.Render("Game Version:")+" "+versionDisplay) + + // Branch + branchDisplay := m.gameBranch + if branchDisplay == "" || branchDisplay == "public" { + branchDisplay = lipgloss.NewStyle().Foreground(Green).Render("public") + } else { + branchDisplay = lipgloss.NewStyle().Foreground(Yellow).Render(branchDisplay) + } + lines = append(lines, " "+lipgloss.NewStyle().Foreground(PurpleLight).Render("🌿")+" "+LabelStyle.Render("Branch:")+" "+branchDisplay) + + // Build ID if available + if m.buildID != "" { + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Gray500).Render("🔢")+" "+RenderKeyValue("Build ID", m.buildID)) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m Model) renderQuickStatsBox() string { + var lines []string + + lines = append(lines, RenderSectionTitle("SSUI Info")) + lines = append(lines, "") + + // Version with branding + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Purple).Bold(true).Render(Rocket)+" "+LabelStyle.Render("Version:")+" "+ValueHighlightStyle.Render(m.ssuiVersion)) + + // Runtime info + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Blue).Render("⚡")+" "+RenderKeyValue("Runtime", m.goRuntime)) + + // Environment with icon + if m.isDocker { + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Cyan).Render("🐳")+" "+LabelStyle.Render("Environment:")+" "+ValueHighlightStyle.Render("Docker")) + } else { + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Green).Render("💻")+" "+LabelStyle.Render("Environment:")+" "+ValueStyle.Render("Native")) + } + + // Session info with animated indicator + sessionUptime := time.Since(m.startTime).Round(time.Second) + lines = append(lines, "") + sessionIcon := GetSpinnerDot(m.tickCount) + lines = append(lines, " "+lipgloss.NewStyle().Foreground(PurpleLight).Render(sessionIcon)+" "+LabelStyle.Render("Session:")+" "+ValueStyle.Render(formatUptime(sessionUptime))) + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +// Players Panel + +func (m Model) renderPlayersPanel() string { + var content string + + if len(m.connectedPlayers) == 0 { + // Empty state with nice styling + emptyIcon := lipgloss.NewStyle(). + Foreground(Gray500). + Render("👥") + + emptyTitle := lipgloss.NewStyle(). + Foreground(Gray400). + Bold(true). + Render("No Players Connected") + + var emptySubtext string + if !m.serverRunning { + emptySubtext = MutedStyle.Render("Server is offline. Press ") + + KeyStyle.Render("s") + + MutedStyle.Render(" to start.") + } else { + // Show animated waiting indicator + spinner := GetSpinnerFrame(m.tickCount) + emptySubtext = lipgloss.NewStyle().Foreground(Cyan).Render(spinner) + + MutedStyle.Render(" Waiting for players to join...") + } + + content = lipgloss.JoinVertical(lipgloss.Center, + "", + "", + emptyIcon, + "", + emptyTitle, + "", + emptySubtext, + "", + "", + ) + + // Center the content + content = lipgloss.NewStyle(). + Width(m.width - 8). + Align(lipgloss.Center). + Render(content) + } else { + // Player list header with player bar + playerBar := RenderProgressBar(len(m.connectedPlayers), m.maxPlayers, 15) + headerLine := lipgloss.JoinHorizontal(lipgloss.Top, + RenderSectionTitle("Connected Players"), + " ", + playerBar, + " ", + NumberStyle.Render(fmt.Sprintf("%d/%d", len(m.connectedPlayers), m.maxPlayers)), + ) + + // Column headers with better styling + colHeader := lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(4).Foreground(Gray600).Render("#"), + lipgloss.NewStyle().Width(26).Foreground(Gray500).Bold(true).Render("Player Name"), + lipgloss.NewStyle().Foreground(Gray500).Bold(true).Render("Steam ID"), + ) + + // Fancy divider + divider := DividerStyle.Render(BoxTeeRight + strings.Repeat(BoxHorizontal, 60) + BoxTeeLeft) + + // Player rows + var rows []string + rows = append(rows, headerLine) + rows = append(rows, "") + rows = append(rows, colHeader) + rows = append(rows, divider) + + // Sort players for consistent display + var steamIDs []string + for steamID := range m.connectedPlayers { + steamIDs = append(steamIDs, steamID) + } + sort.Strings(steamIDs) + + for i, steamID := range steamIDs { + playerName := m.connectedPlayers[steamID] + + // Alternate row styling for readability + indexStyle := lipgloss.NewStyle().Width(4).Foreground(Gray500) + nameStyle := lipgloss.NewStyle().Width(26).Foreground(GreenLight).Bold(true) + idStyle := lipgloss.NewStyle().Foreground(Gray500).Italic(true) + + // Add online indicator + onlineIndicator := lipgloss.NewStyle().Foreground(Green).Render(BulletFilled) + + row := lipgloss.JoinHorizontal(lipgloss.Top, + indexStyle.Render(fmt.Sprintf("%d.", i+1)), + onlineIndicator+" "+nameStyle.Render(playerName), + idStyle.Render(" "+steamID), + ) + rows = append(rows, row) + } + + content = lipgloss.JoinVertical(lipgloss.Left, rows...) + } + + return m.renderPanelContainer(content) +} + +// Log Panel + +func (m Model) renderLogPanel() string { + // Header with scroll indicator + scrollPercent := int(m.logViewport.ScrollPercent() * 100) + scrollIndicator := LogScrollIndicatorStyle.Render(fmt.Sprintf("(%d%%)", scrollPercent)) + + // Scroll position indicator + var scrollHint string + if scrollPercent < 100 { + scrollHint = MutedStyle.Render(" • Use ↑↓ to scroll, 'G' for bottom") + } + + header := lipgloss.JoinHorizontal(lipgloss.Top, + RenderSectionTitle("SSUI Logs"), + " ", + scrollIndicator, + scrollHint, + ) + + // Log content + content := lipgloss.JoinVertical(lipgloss.Left, + header, + "", + m.logViewport.View(), + ) + + // Use a slightly different style for log panel + panelWidth := m.width - 4 + if panelWidth < 40 { + panelWidth = 40 + } + + return LogContainerStyle. + Width(panelWidth). + BorderForeground(Purple). + Render(content) +} + +// Footer + +func (m Model) renderFooter() string { + // Left: Branding + brand := FooterBrandStyle.Render("SSUI") + version := MutedStyle.Render(" v" + m.ssuiVersion) + left := brand + version + + // Center: All key hints in one comprehensive line + var keyHints []string + + keyHints = append(keyHints, + KeyStyle.Render("tab")+KeyDescStyle.Render(" panels"), + KeyStyle.Render("↑↓")+KeyDescStyle.Render(" nav"), + KeyStyle.Render("s")+KeyDescStyle.Render(" start"), + KeyStyle.Render("x")+KeyDescStyle.Render(" stop"), + ) + + // Add config-specific hints when on config panel + if m.activePanel == PanelConfig { + keyHints = append(keyHints, + KeyStyle.Render("enter")+KeyDescStyle.Render(" edit"), + KeyStyle.Render("ctrl+s")+KeyDescStyle.Render(" save"), + ) + } + + keyHints = append(keyHints, + KeyStyle.Render("r")+KeyDescStyle.Render(" refresh"), + KeyStyle.Render("q")+KeyDescStyle.Render(" quit"), + ) + + center := lipgloss.JoinHorizontal(lipgloss.Top, + strings.Join(keyHints, FooterSeparatorStyle.Render(" │ ")), + ) + + // Calculate spacing - simple centered layout + leftWidth := lipgloss.Width(left) + centerWidth := lipgloss.Width(center) + totalContentWidth := leftWidth + centerWidth + + var footerContent string + if m.width > totalContentWidth+10 { + // Full layout with spacing + spacing := max((m.width-totalContentWidth)/2, 2) + footerContent = left + strings.Repeat(" ", spacing) + center + } else { + // Compact layout + footerContent = left + " " + center + } + + return FooterStyle.Width(m.width).Render(footerContent) +} + +// Helper Functions + +// renderPanelContainer wraps content in the standard panel style +func (m Model) renderPanelContainer(content string) string { + // Calculate consistent content height + headerHeight := 6 // Status bar + tabs + footerHeight := 2 // Footer + panelPadding := 4 // Border + padding + + contentHeight := max(m.height-headerHeight-footerHeight-panelPadding, 10) + + panelWidth := m.width - 4 + if panelWidth < 40 { + panelWidth = 40 + } + + return PanelStyle. + Width(panelWidth). + Height(contentHeight). + Render(content) +} diff --git a/src/cli/ssuicli.go b/src/cli/ssuicli.go index f343f5fe..625444b9 100644 --- a/src/cli/ssuicli.go +++ b/src/cli/ssuicli.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/cli/dashboard" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" ) @@ -54,6 +55,18 @@ func StartConsole(wg *sync.WaitGroup) { wg.Add(1) go func() { defer wg.Done() + + // Auto-launch dashboard on interactive terminals if enabled in config + if config.GetIsCLIDashboardEnabled() && dashboard.IsInteractiveTerminal() { + time.Sleep(3 * time.Second) // Give other subsystems time to initialize + logger.Core.Info("CLI Dashboard is enabled, launching...") + time.Sleep(500 * time.Millisecond) // Small delay for log to be visible + if err := dashboard.Run(); err != nil { + logger.Core.Error("Dashboard exited with error: " + err.Error()) + } + logger.Core.Info("Dashboard closed, returning to SSUICLI prompt...") + } + scanner := bufio.NewScanner(os.Stdin) logger.Core.Info("SSUICLI runtime console started. Type 'help' for commands.") time.Sleep(10 * time.Millisecond) diff --git a/src/config/config.go b/src/config/config.go index 146ffe41..3fb6d76a 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -11,7 +11,7 @@ import ( var ( // All configuration variables can be found in vars.go - Version = "5.12.1" + Version = "5.12.3" Branch = "release" ) @@ -71,6 +71,7 @@ type JsonConfig struct { IsSSCMEnabled *bool `json:"IsSSCMEnabled"` AutoRestartServerTimer string `json:"AutoRestartServerTimer"` IsConsoleEnabled *bool `json:"IsConsoleEnabled"` + IsCLIDashboardEnabled *bool `json:"IsCLIDashboardEnabled"` LanguageSetting string `json:"LanguageSetting"` AutoStartServerOnStartup *bool `json:"AutoStartServerOnStartup"` SSUIIdentifier string `json:"SSUIIdentifier"` @@ -283,6 +284,10 @@ func applyConfig(cfg *JsonConfig) { IsConsoleEnabled = isConsoleEnabledVal cfg.IsConsoleEnabled = &isConsoleEnabledVal + isCLIDashboardEnabledVal := getBool(cfg.IsCLIDashboardEnabled, "IS_CLI_DASHBOARD_ENABLED", false) + IsCLIDashboardEnabled = isCLIDashboardEnabledVal + cfg.IsCLIDashboardEnabled = &isCLIDashboardEnabledVal + logClutterToConsoleVal := getBool(cfg.LogClutterToConsole, "LOG_CLUTTER_TO_CONSOLE", false) LogClutterToConsole = logClutterToConsoleVal cfg.LogClutterToConsole = &logClutterToConsoleVal @@ -413,6 +418,7 @@ func safeSaveConfig() error { IsStationeersLaunchPadEnabled: &IsStationeersLaunchPadEnabled, IsStationeersLaunchPadAutoUpdatesEnabled: &IsStationeersLaunchPadAutoUpdatesEnabled, IsConsoleEnabled: &IsConsoleEnabled, + IsCLIDashboardEnabled: &IsCLIDashboardEnabled, LanguageSetting: LanguageSetting, AutoStartServerOnStartup: &AutoStartServerOnStartup, SSUIIdentifier: SSUIIdentifier, diff --git a/src/config/getters.go b/src/config/getters.go index 91e885ef..97a5b7c2 100644 --- a/src/config/getters.go +++ b/src/config/getters.go @@ -394,6 +394,12 @@ func GetIsConsoleEnabled() bool { return IsConsoleEnabled } +func GetIsCLIDashboardEnabled() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return IsCLIDashboardEnabled +} + func GetLanguageSetting() string { ConfigMu.RLock() defer ConfigMu.RUnlock() diff --git a/src/config/setters.go b/src/config/setters.go index 3e838375..d3767f7c 100644 --- a/src/config/setters.go +++ b/src/config/setters.go @@ -695,6 +695,14 @@ func SetIsConsoleEnabled(value bool) error { return safeSaveConfig() } +func SetIsCLIDashboardEnabled(value bool) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + IsCLIDashboardEnabled = value + return safeSaveConfig() +} + func SetAllowAutoGameServerUpdates(value bool) error { ConfigMu.Lock() defer ConfigMu.Unlock() diff --git a/src/config/vars.go b/src/config/vars.go index f847c8b5..f4d02c07 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -58,6 +58,7 @@ var ( SubsystemFilters []string AutoRestartServerTimer string IsConsoleEnabled bool + IsCLIDashboardEnabled bool LogClutterToConsole bool // surpresses clutter mono logs from the gameserver LanguageSetting string AutoStartServerOnStartup bool diff --git a/src/core/loader/afterstart.go b/src/core/loader/afterstart.go index 1e72fee7..1629525c 100644 --- a/src/core/loader/afterstart.go +++ b/src/core/loader/afterstart.go @@ -1,6 +1,8 @@ package loader import ( + "time" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/discordrpc" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" @@ -26,11 +28,10 @@ func AfterStartComplete() { //setup.SetupAutostartScripts() discordrpc.StartDiscordRPC() - go func() { - //time.Sleep(10 * time.Second) - printStartupMessage() - if config.GetIsFirstTimeSetup() { - printFirstTimeSetupMessage() - } - }() + time.Sleep(500 * time.Millisecond) + printStartupMessage() + + if config.GetIsFirstTimeSetup() { + printFirstTimeSetupMessage() + } } diff --git a/src/core/loader/clisetup.go b/src/core/loader/clisetup.go new file mode 100644 index 00000000..d52319e3 --- /dev/null +++ b/src/core/loader/clisetup.go @@ -0,0 +1,73 @@ +// clisetup.go - Interactive CLI setup track for first-time configuration +package loader + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/mattn/go-isatty" +) + +// ANSI color codes +const ( + colorReset = "\033[0m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorCyan = "\033[36m" + colorPurple = "\033[35m" + colorBold = "\033[1m" +) + +// promptStyle adds consistent styling to prompts +func promptStyle(s string) string { + return colorPurple + colorBold + s + colorReset +} + +// IsInteractiveTerminal checks if we're running in an interactive terminal +func IsInteractiveTerminal() bool { + return isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd()) +} + +// PromptSetupTrackChoice asks the user which setup track they want to use +// Returns "cli", "web", or "skip" +func PromptSetupTrackChoice() string { + if !IsInteractiveTerminal() { + // Non-interactive, default to web silently + return "web" + } + + reader := bufio.NewReader(os.Stdin) + + fmt.Println() + fmt.Println(promptStyle(" " + colorBold + "SSUI First-Time Setup" + colorReset)) + fmt.Println(promptStyle(" How would you like to configure your server? ")) + fmt.Println(promptStyle(" ")) + fmt.Println(promptStyle(" [1] " + colorGreen + "CLI Setup" + colorReset + " - Configure right here in the terminal")) + fmt.Println(promptStyle(" [2] " + colorCyan + "Web Setup" + colorReset + " - Use the browser-based setup wizard")) + fmt.Println(promptStyle(" [3] " + colorYellow + "Skip" + colorReset + " - Use defaults and configure later")) + fmt.Println() + fmt.Print(promptStyle("\n Enter choice [1/2/3]: ")) + + input, err := reader.ReadString('\n') + if err != nil { + return "web" + } + + input = strings.TrimSpace(input) + switch input { + case "1", "cli", "CLI": + return "cli" + case "2", "web", "Web", "WEB": + return "web" + case "3", "skip", "Skip", "SKIP": + // Mark setup as complete so it doesn't prompt on web UI either + config.SetIsFirstTimeSetup(false) + return "skip" + default: + fmt.Println(colorCyan + " Invalid choice, defaulting to Web Setup..." + colorReset) + return "web" + } +} diff --git a/src/core/loader/terminalmsg.go b/src/core/loader/terminalmsg.go index 588eed6f..683b5dfe 100644 --- a/src/core/loader/terminalmsg.go +++ b/src/core/loader/terminalmsg.go @@ -40,12 +40,12 @@ func printStartupMessage() { func printFirstTimeSetupMessage() { // Setup guide + logger.Core.Cleanf("") logger.Core.Cleanf(" 📋 GETTING STARTED:") logger.Core.Cleanf(" ┌─────────────────────────────────────────────────────────────────────────────────────────────┐") logger.Core.Cleanf(" │ • Ready, set, go! Welcome to StationeersServerUI, new User! │") logger.Core.Cleanf(" │ • The good news: you made it here, which means you are likely ready to run your server! │") logger.Core.Cleanf(" │ • If this is your first time here, no worries: SSUI is made to be easy to use. │") - logger.Core.Cleanf(" │ • Configure your server by visiting the WebUI! │") logger.Core.Cleanf(" │ • Support is provided at https://discord.gg/8n3vN92MyJ │") logger.Core.Cleanf(" │ • For more details, check the GitHub Wiki: │") logger.Core.Cleanf(" │ • https://github.com/SteamServerUI/StationeersServerUI/v5/wiki │") diff --git a/src/logger/logger.go b/src/logger/logger.go index 719c08c3..45fdd7a9 100644 --- a/src/logger/logger.go +++ b/src/logger/logger.go @@ -107,7 +107,7 @@ type logEntry struct { func (l *Logger) Init() { globalOnce.Do(func() { globalLogChan = make(chan logEntry, 1000) // Buffered global channel for log processing - globalConsoleChan = make(chan string, 20) // Buffered global channel for console output + globalConsoleChan = make(chan string, 50) // Buffered global channel for console output go processLogs() go processConsoleOutput() }) @@ -223,6 +223,12 @@ func processLogs() { entry.logger.writeToFile(entry.fileLine, entry.logger.suffix) } + // Check if dashboard is active - if so, capture logs for dashboard display and suppress console output + if isDashboardActive() { + captureDashboardLog(entry.fileLine) + continue // Skip stdout console output while dashboard is running + } + // Send to global console channel select { case globalConsoleChan <- entry.consoleLine: @@ -258,3 +264,42 @@ func (l *Logger) shouldLog(severity int) bool { } return severity >= config.GetLogLevel() } + +// AI figured below part out, but it works great. Import cycles are my villain here, +// and this seems like a neat way to allow the dashboard to capture logs without importing the dashboard package +// into the logger. This avoids circular dependencies. + +// Dashboard integration hooks - these allow the dashboard to intercept console output +var ( + dashboardHooksMutex sync.Mutex + dashboardActiveFunc func() bool + dashboardCaptureFunc func(string) +) + +// RegisterDashboardHooks allows the dashboard package to register its functions +// for checking active state and capturing logs. This avoids import cycles. +func RegisterDashboardHooks(activeFunc func() bool, captureFunc func(string)) { + dashboardHooksMutex.Lock() + dashboardActiveFunc = activeFunc + dashboardCaptureFunc = captureFunc + dashboardHooksMutex.Unlock() +} + +// isDashboardActive checks if the dashboard is currently running +func isDashboardActive() bool { + dashboardHooksMutex.Lock() + defer dashboardHooksMutex.Unlock() + if dashboardActiveFunc == nil { + return false + } + return dashboardActiveFunc() +} + +// captureDashboardLog sends a log line to the dashboard for display +func captureDashboardLog(line string) { + dashboardHooksMutex.Lock() + defer dashboardHooksMutex.Unlock() + if dashboardCaptureFunc != nil { + dashboardCaptureFunc(line) + } +} diff --git a/src/managers/gamemgr/processmanagement.go b/src/managers/gamemgr/processmanagement.go index fbd46bff..7a754648 100644 --- a/src/managers/gamemgr/processmanagement.go +++ b/src/managers/gamemgr/processmanagement.go @@ -132,6 +132,7 @@ func InternalStartServer() error { } // create a UUID for this specific run createGameServerUUID() + setServerStartTime() config.SetIsGameServerRunning(true) // Start auto-restart goroutine if AutoRestartServerTimer is set greater than 0 @@ -222,6 +223,7 @@ func InternalStopServer() error { // Process is confirmed stopped, clear cmd cmd = nil config.SetIsGameServerRunning(false) + clearServerStartTime() clearGameServerUUID() return nil } diff --git a/src/managers/gamemgr/uptime.go b/src/managers/gamemgr/uptime.go new file mode 100644 index 00000000..86398280 --- /dev/null +++ b/src/managers/gamemgr/uptime.go @@ -0,0 +1,63 @@ +package gamemgr + +import ( + "fmt" + "sync" + "time" +) + +var ( + serverStartTime time.Time + uptimeMu sync.RWMutex +) + +func setServerStartTime() { + uptimeMu.Lock() + defer uptimeMu.Unlock() + serverStartTime = time.Now() +} + +func clearServerStartTime() { + uptimeMu.Lock() + defer uptimeMu.Unlock() + serverStartTime = time.Time{} +} + +// GetServerUptime returns the server uptime as a DURATION. +// Returns 0 if the server is not running. +func GetServerUptime() time.Duration { + uptimeMu.RLock() + defer uptimeMu.RUnlock() + if serverStartTime.IsZero() { + return 0 + } + return time.Since(serverStartTime) +} + +// GetServerStartTime returns the time WHEN the server was started. +// Returns zero time if the server is not running. +func GetServerStartTime() time.Time { + uptimeMu.RLock() + defer uptimeMu.RUnlock() + return serverStartTime +} + +// FormatUptime formats the uptime duration into a human-readable string +func FormatUptime(d time.Duration) string { + if d == 0 { + return "N/A" + } + + d = d.Round(time.Second) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + + if h > 0 { + return fmt.Sprintf("%dh %dm %ds", h, m, s) + } + if m > 0 { + return fmt.Sprintf("%dm %ds", m, s) + } + return fmt.Sprintf("%ds", s) +} diff --git a/src/web/configpage.go b/src/web/configpage.go index d501fb93..da176c35 100644 --- a/src/web/configpage.go +++ b/src/web/configpage.go @@ -208,20 +208,12 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { authEnabledFalseSelected = "selected" } - isCleanupEnabledTrueSelected := "" - isCleanupEnabledFalseSelected := "" - if config.GetIsCleanupEnabled() { - isCleanupEnabledTrueSelected = "selected" - } else { - isCleanupEnabledFalseSelected = "selected" - } - - isStationeersLaunchPadAutoUpdatesEnabledTrue := "" - isStationeersLaunchPadAutoUpdatesEnabledFalse := "" + isStationeersLaunchPadAutoUpdatesEnabledTrueSelected := "" + isStationeersLaunchPadAutoUpdatesEnabledFalseSelected := "" if config.GetIsStationeersLaunchPadAutoUpdatesEnabled() { - isStationeersLaunchPadAutoUpdatesEnabledTrue = "selected" + isStationeersLaunchPadAutoUpdatesEnabledTrueSelected = "selected" } else { - isStationeersLaunchPadAutoUpdatesEnabledFalse = "selected" + isStationeersLaunchPadAutoUpdatesEnabledFalseSelected = "selected" } data := ConfigTemplateData{ @@ -434,47 +426,38 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { ShowExpertSettingsFalseSelected: showExpertSettingsFalseSelected, // Expert Settings values - Debug: fmt.Sprintf("%v", config.GetIsDebugMode()), - DebugTrueSelected: debugTrueSelected, - DebugFalseSelected: debugFalseSelected, - LogLevel: fmt.Sprintf("%d", config.GetLogLevel()), - LogClutterToConsole: fmt.Sprintf("%v", config.GetLogClutterToConsole()), - LogClutterToConsoleTrueSelected: logClutterToConsoleTrueSelected, - LogClutterToConsoleFalseSelected: logClutterToConsoleFalseSelected, - IsSSCMEnabled: fmt.Sprintf("%v", config.GetIsSSCMEnabled()), - IsSSCMEnabledTrueSelected: isSSCMEnabledTrueSelected, - IsSSCMEnabledFalseSelected: isSSCMEnabledFalseSelected, - IsConsoleEnabled: fmt.Sprintf("%v", config.GetIsConsoleEnabled()), - IsConsoleEnabledTrueSelected: isConsoleEnabledTrueSelected, - IsConsoleEnabledFalseSelected: isConsoleEnabledFalseSelected, - SSUIWebPort: config.GetSSUIWebPort(), - IsUpdateEnabled: fmt.Sprintf("%v", config.GetIsUpdateEnabled()), - IsUpdateEnabledTrueSelected: isUpdateEnabledTrueSelected, - IsUpdateEnabledFalseSelected: isUpdateEnabledFalseSelected, - AllowPrereleaseUpdates: fmt.Sprintf("%v", config.GetAllowPrereleaseUpdates()), - AllowPrereleaseUpdatesTrueSelected: allowPrereleaseUpdatesTrueSelected, - AllowPrereleaseUpdatesFalseSelected: allowPrereleaseUpdatesFalseSelected, - AllowMajorUpdates: fmt.Sprintf("%v", config.GetAllowMajorUpdates()), - AllowMajorUpdatesTrueSelected: allowMajorUpdatesTrueSelected, - AllowMajorUpdatesFalseSelected: allowMajorUpdatesFalseSelected, - AuthEnabled: fmt.Sprintf("%v", config.GetAuthEnabled()), - AuthEnabledTrueSelected: authEnabledTrueSelected, - AuthEnabledFalseSelected: authEnabledFalseSelected, - AuthTokenLifetime: fmt.Sprintf("%d", config.GetAuthTokenLifetime()), - DiscordCharBufferSize: fmt.Sprintf("%d", config.GetDiscordCharBufferSize()), - BackupKeepLastN: fmt.Sprintf("%d", config.GetBackupKeepLastN()), - IsCleanupEnabled: fmt.Sprintf("%v", config.GetIsCleanupEnabled()), - IsCleanupEnabledTrueSelected: isCleanupEnabledTrueSelected, - IsCleanupEnabledFalseSelected: isCleanupEnabledFalseSelected, - BackupKeepDailyFor: fmt.Sprintf("%d", int(config.GetBackupKeepDailyFor().Hours())), - BackupKeepWeeklyFor: fmt.Sprintf("%d", int(config.GetBackupKeepWeeklyFor().Hours())), - BackupKeepMonthlyFor: fmt.Sprintf("%d", int(config.GetBackupKeepMonthlyFor().Hours())), - BackupCleanupInterval: fmt.Sprintf("%d", int(config.GetBackupCleanupInterval().Hours())), - BackupWaitTime: fmt.Sprintf("%d", int(config.GetBackupWaitTime().Seconds())), - AdvertiserOverride: config.GetAdvertiserOverride(), - IsStationeersLaunchPadAutoUpdatesEnabled: fmt.Sprintf("%v", config.GetIsStationeersLaunchPadAutoUpdatesEnabled()), - IsStationeersLaunchPadAutoUpdatesEnabledTrue: isStationeersLaunchPadAutoUpdatesEnabledTrue, - IsStationeersLaunchPadAutoUpdatesEnabledFalse: isStationeersLaunchPadAutoUpdatesEnabledFalse, + Debug: fmt.Sprintf("%v", config.GetIsDebugMode()), + DebugTrueSelected: debugTrueSelected, + DebugFalseSelected: debugFalseSelected, + LogLevel: fmt.Sprintf("%d", config.GetLogLevel()), + LogClutterToConsole: fmt.Sprintf("%v", config.GetLogClutterToConsole()), + LogClutterToConsoleTrueSelected: logClutterToConsoleTrueSelected, + LogClutterToConsoleFalseSelected: logClutterToConsoleFalseSelected, + IsSSCMEnabled: fmt.Sprintf("%v", config.GetIsSSCMEnabled()), + IsSSCMEnabledTrueSelected: isSSCMEnabledTrueSelected, + IsSSCMEnabledFalseSelected: isSSCMEnabledFalseSelected, + IsConsoleEnabled: fmt.Sprintf("%v", config.GetIsConsoleEnabled()), + IsConsoleEnabledTrueSelected: isConsoleEnabledTrueSelected, + IsConsoleEnabledFalseSelected: isConsoleEnabledFalseSelected, + SSUIWebPort: config.GetSSUIWebPort(), + IsUpdateEnabled: fmt.Sprintf("%v", config.GetIsUpdateEnabled()), + IsUpdateEnabledTrueSelected: isUpdateEnabledTrueSelected, + IsUpdateEnabledFalseSelected: isUpdateEnabledFalseSelected, + AllowPrereleaseUpdates: fmt.Sprintf("%v", config.GetAllowPrereleaseUpdates()), + AllowPrereleaseUpdatesTrueSelected: allowPrereleaseUpdatesTrueSelected, + AllowPrereleaseUpdatesFalseSelected: allowPrereleaseUpdatesFalseSelected, + AllowMajorUpdates: fmt.Sprintf("%v", config.GetAllowMajorUpdates()), + AllowMajorUpdatesTrueSelected: allowMajorUpdatesTrueSelected, + AllowMajorUpdatesFalseSelected: allowMajorUpdatesFalseSelected, + AuthEnabled: fmt.Sprintf("%v", config.GetAuthEnabled()), + AuthEnabledTrueSelected: authEnabledTrueSelected, + AuthEnabledFalseSelected: authEnabledFalseSelected, + AuthTokenLifetime: fmt.Sprintf("%d", config.GetAuthTokenLifetime()), + DiscordCharBufferSize: fmt.Sprintf("%d", config.GetDiscordCharBufferSize()), + AdvertiserOverride: config.GetAdvertiserOverride(), + IsStationeersLaunchPadAutoUpdatesEnabled: fmt.Sprintf("%v", config.GetIsStationeersLaunchPadAutoUpdatesEnabled()), + IsStationeersLaunchPadAutoUpdatesEnabledTrueSelected: isStationeersLaunchPadAutoUpdatesEnabledTrueSelected, + IsStationeersLaunchPadAutoUpdatesEnabledFalseSelected: isStationeersLaunchPadAutoUpdatesEnabledFalseSelected, } err = tmpl.Execute(w, data) diff --git a/src/web/templatevars.go b/src/web/templatevars.go index c8941d83..810c7eb7 100644 --- a/src/web/templatevars.go +++ b/src/web/templatevars.go @@ -238,45 +238,45 @@ type ConfigTemplateData struct { ShowExpertSettingsFalseSelected string // Expert Settings values - Debug string - DebugTrueSelected string - DebugFalseSelected string - LogLevel string - LogClutterToConsole string - LogClutterToConsoleTrueSelected string - LogClutterToConsoleFalseSelected string - IsSSCMEnabled string - IsSSCMEnabledTrueSelected string - IsSSCMEnabledFalseSelected string - IsConsoleEnabled string - IsConsoleEnabledTrueSelected string - IsConsoleEnabledFalseSelected string - SSUIWebPort string - IsUpdateEnabled string - IsUpdateEnabledTrueSelected string - IsUpdateEnabledFalseSelected string - AllowPrereleaseUpdates string - AllowPrereleaseUpdatesTrueSelected string - AllowPrereleaseUpdatesFalseSelected string - AllowMajorUpdates string - AllowMajorUpdatesTrueSelected string - AllowMajorUpdatesFalseSelected string - AuthEnabled string - AuthEnabledTrueSelected string - AuthEnabledFalseSelected string - AuthTokenLifetime string - DiscordCharBufferSize string - BackupKeepLastN string - IsCleanupEnabled string - IsCleanupEnabledTrueSelected string - IsCleanupEnabledFalseSelected string - BackupKeepDailyFor string - BackupKeepWeeklyFor string - BackupKeepMonthlyFor string - BackupCleanupInterval string - BackupWaitTime string - AdvertiserOverride string - IsStationeersLaunchPadAutoUpdatesEnabled string - IsStationeersLaunchPadAutoUpdatesEnabledTrue string - IsStationeersLaunchPadAutoUpdatesEnabledFalse string + Debug string + DebugTrueSelected string + DebugFalseSelected string + LogLevel string + LogClutterToConsole string + LogClutterToConsoleTrueSelected string + LogClutterToConsoleFalseSelected string + IsSSCMEnabled string + IsSSCMEnabledTrueSelected string + IsSSCMEnabledFalseSelected string + IsConsoleEnabled string + IsConsoleEnabledTrueSelected string + IsConsoleEnabledFalseSelected string + SSUIWebPort string + IsUpdateEnabled string + IsUpdateEnabledTrueSelected string + IsUpdateEnabledFalseSelected string + AllowPrereleaseUpdates string + AllowPrereleaseUpdatesTrueSelected string + AllowPrereleaseUpdatesFalseSelected string + AllowMajorUpdates string + AllowMajorUpdatesTrueSelected string + AllowMajorUpdatesFalseSelected string + AuthEnabled string + AuthEnabledTrueSelected string + AuthEnabledFalseSelected string + AuthTokenLifetime string + DiscordCharBufferSize string + BackupKeepLastN string + IsCleanupEnabled string + IsCleanupEnabledTrueSelected string + IsCleanupEnabledFalseSelected string + BackupKeepDailyFor string + BackupKeepWeeklyFor string + BackupKeepMonthlyFor string + BackupCleanupInterval string + BackupWaitTime string + AdvertiserOverride string + IsStationeersLaunchPadAutoUpdatesEnabled string + IsStationeersLaunchPadAutoUpdatesEnabledTrueSelected string + IsStationeersLaunchPadAutoUpdatesEnabledFalseSelected string }