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