{{.UIText_ServerConfig}}
+{{.UIText_ConfigHeadline}}
Expert Settings below - only change if you know what you are doing
+{{.UIText_TerrainSettingsHeader}}
{{.UIText_SLP_InstalledMods}}
Custom Detection Manager
Custom detections allow you to create custom patterns for event detection. These patterns can be used to detect specific events in the server log and send alerts to the "Status" channel in Discord and will be shown in the Events tab on the main dashboard.
To create a custom detection, you can either define a regex pattern or
diff --git a/go.mod b/go.mod
index 679eae5c..2967a033 100644
--- a/go.mod
+++ b/go.mod
@@ -4,12 +4,36 @@ 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/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..460aa971 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,33 @@
+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 +39,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..8708ce91
--- /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.GetGameBranch(),
+ 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 ddb525cb..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.11.1"
+ Version = "5.12.3"
Branch = "release"
)
@@ -71,10 +71,12 @@ 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"`
SSUIWebPort string `json:"SSUIWebPort"`
+ ShowExpertSettings *bool `json:"ShowExpertSettings"` // Show Expert Settings tab in web UI
// Update Settings
IsUpdateEnabled *bool `json:"IsUpdateEnabled"`
@@ -87,17 +89,19 @@ type JsonConfig struct {
IsStationeersLaunchPadAutoUpdatesEnabled *bool `json:"IsStationeersLaunchPadAutoUpdatesEnabled"`
// Discord Settings
- DiscordToken string `json:"discordToken"`
- ControlChannelID string `json:"controlChannelID"`
- StatusChannelID string `json:"statusChannelID"`
- ConnectionListChannelID string `json:"connectionListChannelID"`
- LogChannelID string `json:"logChannelID"`
- SaveChannelID string `json:"saveChannelID"`
- ControlPanelChannelID string `json:"controlPanelChannelID"`
- DiscordCharBufferSize int `json:"DiscordCharBufferSize"`
- BlackListFilePath string `json:"blackListFilePath"`
- IsDiscordEnabled *bool `json:"isDiscordEnabled"`
- ErrorChannelID string `json:"errorChannelID"`
+ DiscordToken string `json:"discordToken"`
+ ControlChannelID string `json:"controlChannelID"`
+ StatusChannelID string `json:"statusChannelID"`
+ ConnectionListChannelID string `json:"connectionListChannelID"`
+ LogChannelID string `json:"logChannelID"`
+ SaveChannelID string `json:"saveChannelID"`
+ ControlPanelChannelID string `json:"controlPanelChannelID"`
+ ServerInfoPanelChannelID string `json:"serverInfoPanelChannelID"`
+ DiscordCharBufferSize int `json:"DiscordCharBufferSize"`
+ BlackListFilePath string `json:"blackListFilePath"`
+ IsDiscordEnabled *bool `json:"isDiscordEnabled"`
+ RotateServerPassword *bool `json:"rotateServerPassword"`
+ ErrorChannelID string `json:"errorChannelID"`
//Backup Settings
BackupKeepLastN int `json:"backupKeepLastN"` // Number of most recent backups to keep (default: 2000)
@@ -146,6 +150,7 @@ func applyConfig(cfg *JsonConfig) {
LogChannelID = getString(cfg.LogChannelID, "LOG_CHANNEL_ID", "")
SaveChannelID = getString(cfg.SaveChannelID, "SAVE_CHANNEL_ID", "")
ControlPanelChannelID = getString(cfg.ControlPanelChannelID, "CONTROL_PANEL_CHANNEL_ID", "")
+ ServerInfoPanelChannelID = getString(cfg.ServerInfoPanelChannelID, "SERVER_INFO_PANEL_CHANNEL_ID", "")
DiscordCharBufferSize = getInt(cfg.DiscordCharBufferSize, "DISCORD_CHAR_BUFFER_SIZE", 1000)
BlackListFilePath = getString(cfg.BlackListFilePath, "BLACKLIST_FILE_PATH", "./Blacklist.txt")
@@ -153,6 +158,10 @@ func applyConfig(cfg *JsonConfig) {
IsDiscordEnabled = isDiscordEnabledVal
cfg.IsDiscordEnabled = &isDiscordEnabledVal
+ rotateServerPasswordVal := getBool(cfg.RotateServerPassword, "ROTATE_SERVER_PASSWORD", false)
+ RotateServerPassword = rotateServerPasswordVal
+ cfg.RotateServerPassword = &rotateServerPasswordVal
+
ErrorChannelID = getString(cfg.ErrorChannelID, "ERROR_CHANNEL_ID", "")
BackupKeepLastN = getInt(cfg.BackupKeepLastN, "BACKUP_KEEP_LAST_N", 2000)
@@ -275,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
@@ -283,6 +296,10 @@ func applyConfig(cfg *JsonConfig) {
AutoStartServerOnStartup = autoStartServerOnStartupVal
cfg.AutoStartServerOnStartup = &autoStartServerOnStartupVal
+ showExpertSettingsVal := getBool(cfg.ShowExpertSettings, "SHOW_EXPERT_SETTINGS", false)
+ ShowExpertSettings = showExpertSettingsVal
+ cfg.ShowExpertSettings = &showExpertSettingsVal
+
// Process SaveInfo to maintain backwards compatibility with pre-5.6.6 SaveInfo field (deprecated)
if SaveInfo != "" {
parts := strings.Split(SaveInfo, " ")
@@ -345,9 +362,11 @@ func safeSaveConfig() error {
LogChannelID: LogChannelID,
SaveChannelID: SaveChannelID,
ControlPanelChannelID: ControlPanelChannelID,
+ ServerInfoPanelChannelID: ServerInfoPanelChannelID,
DiscordCharBufferSize: DiscordCharBufferSize,
BlackListFilePath: BlackListFilePath,
IsDiscordEnabled: &IsDiscordEnabled,
+ RotateServerPassword: &RotateServerPassword,
ErrorChannelID: ErrorChannelID,
BackupKeepLastN: BackupKeepLastN,
IsCleanupEnabled: &IsCleanupEnabled,
@@ -399,11 +418,13 @@ func safeSaveConfig() error {
IsStationeersLaunchPadEnabled: &IsStationeersLaunchPadEnabled,
IsStationeersLaunchPadAutoUpdatesEnabled: &IsStationeersLaunchPadAutoUpdatesEnabled,
IsConsoleEnabled: &IsConsoleEnabled,
+ IsCLIDashboardEnabled: &IsCLIDashboardEnabled,
LanguageSetting: LanguageSetting,
AutoStartServerOnStartup: &AutoStartServerOnStartup,
SSUIIdentifier: SSUIIdentifier,
SSUIWebPort: SSUIWebPort,
AdvertiserOverride: AdvertiserOverride,
+ ShowExpertSettings: &ShowExpertSettings,
}
file, err := os.Create(ConfigPath)
diff --git a/src/config/getters.go b/src/config/getters.go
index 16a12724..97a5b7c2 100644
--- a/src/config/getters.go
+++ b/src/config/getters.go
@@ -46,6 +46,12 @@ func GetControlPanelChannelID() string {
return ControlPanelChannelID
}
+func GetServerInfoPanelChannelID() string {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ServerInfoPanelChannelID
+}
+
func GetDiscordCharBufferSize() int {
ConfigMu.RLock()
defer ConfigMu.RUnlock()
@@ -64,6 +70,12 @@ func GetIsDiscordEnabled() bool {
return IsDiscordEnabled
}
+func GetRotateServerPassword() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return RotateServerPassword
+}
+
func GetErrorChannelID() string {
ConfigMu.RLock()
defer ConfigMu.RUnlock()
@@ -109,6 +121,13 @@ func GetBackupCleanupInterval() time.Duration {
return BackupCleanupInterval
}
+// GetBackupWaitTime returns the backup wait time in seconds.
+func GetBackupWaitTime() time.Duration {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return BackupWaitTime
+}
+
func GetIsNewTerrainAndSaveSystem() bool {
ConfigMu.RLock()
defer ConfigMu.RUnlock()
@@ -375,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()
@@ -399,6 +424,12 @@ func GetSSUIWebPort() string {
return SSUIWebPort
}
+func GetShowExpertSettings() bool {
+ ConfigMu.RLock()
+ defer ConfigMu.RUnlock()
+ return ShowExpertSettings
+}
+
// GetIsFirstTimeSetup returns the IsFirstTimeSetup
func GetIsFirstTimeSetup() bool {
ConfigMu.RLock()
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 e9f6f57b..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
@@ -65,6 +66,7 @@ var (
AdvertiserOverride string
IsStationeersLaunchPadEnabled bool
IsStationeersLaunchPadAutoUpdatesEnabled bool
+ ShowExpertSettings bool
)
// Runtime only variables
@@ -80,19 +82,21 @@ var (
// Discord integration
var (
- DiscordToken string
- DiscordSession *discordgo.Session
- IsDiscordEnabled bool
- ControlChannelID string
- StatusChannelID string
- LogChannelID string
- ErrorChannelID string
- ConnectionListChannelID string
- SaveChannelID string
- ControlPanelChannelID string
- DiscordCharBufferSize int
- ExceptionMessageID string
- BlackListFilePath string
+ DiscordToken string
+ DiscordSession *discordgo.Session
+ IsDiscordEnabled bool
+ RotateServerPassword bool
+ ControlChannelID string
+ StatusChannelID string
+ LogChannelID string
+ ErrorChannelID string
+ ConnectionListChannelID string
+ SaveChannelID string
+ ControlPanelChannelID string
+ ServerInfoPanelChannelID string
+ DiscordCharBufferSize int
+ ExceptionMessageID string
+ BlackListFilePath string
)
// Backup and cleanup settings
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/helpers.go b/src/core/loader/helpers.go
index fccb5e1b..a53265b2 100644
--- a/src/core/loader/helpers.go
+++ b/src/core/loader/helpers.go
@@ -91,12 +91,6 @@ func PrintConfigDetails(logLevel ...string) {
// Backup Configuration
backup := map[string]string{
- "BackupKeepLastN": fmt.Sprintf("%d", config.GetBackupKeepLastN()),
- "IsCleanupEnabled": fmt.Sprintf("%v", config.GetIsCleanupEnabled()),
- "BackupKeepDailyFor": fmt.Sprintf("%v", config.GetBackupKeepDailyFor()),
- "BackupKeepWeeklyFor": fmt.Sprintf("%v", config.GetBackupKeepWeeklyFor()),
- "BackupKeepMonthlyFor": fmt.Sprintf("%v", config.GetBackupKeepMonthlyFor()),
- "BackupCleanupInterval": fmt.Sprintf("%v", config.GetBackupCleanupInterval()),
"ConfiguredBackupDir": config.GetConfiguredBackupDir(),
"ConfiguredSafeBackupDir": config.GetConfiguredSafeBackupDir(),
}
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/discordbot/handleSlashcommands.go b/src/discordbot/handleSlashcommands.go
index d2b0b9ff..88647a02 100644
--- a/src/discordbot/handleSlashcommands.go
+++ b/src/discordbot/handleSlashcommands.go
@@ -1,6 +1,7 @@
package discordbot
import (
+ "bytes"
"fmt"
"sort"
"strconv"
@@ -27,6 +28,7 @@ var handlers = map[string]commandHandler{
"help": handleHelp,
"restore": handleRestore,
"list": handleList,
+ "download": handleDownload,
"bansteamid": handleBan,
"unbansteamid": handleUnban,
"update": handleUpdate,
@@ -142,6 +144,7 @@ func handleHelp(s *discordgo.Session, i *discordgo.InteractionCreate, data Embed
{Name: "/update", Value: "Updates the gameserver via SteamCMD"},
{Name: "/list [limit]", Value: "Lists recent backups (default: 5)"},
{Name: "/restore