diff --git a/.github/workflows/ghcr-build-nightly.yaml b/.github/workflows/ghcr-build-nightly.yaml index 7fcfe13e..7edbcb07 100644 --- a/.github/workflows/ghcr-build-nightly.yaml +++ b/.github/workflows/ghcr-build-nightly.yaml @@ -22,7 +22,7 @@ jobs: - name: Lowercase the repo name run: | - echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}-nightly" >> $GITHUB_ENV - name: Extract metadata id: meta @@ -31,8 +31,7 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=nightly - flavor: | - latest=false + type=raw,value=latest - name: Set up QEMU uses: docker/setup-qemu-action@v3 diff --git a/UIMod/onboard_bundled/assets/css/config.css b/UIMod/onboard_bundled/assets/css/config.css index 13de4173..f4dea0d1 100644 --- a/UIMod/onboard_bundled/assets/css/config.css +++ b/UIMod/onboard_bundled/assets/css/config.css @@ -140,6 +140,13 @@ background-position: center; } +/*Back icon - left arrow*/ +.back-icon { + background-image: url("/static/icons/back.webp"); + background-repeat: no-repeat; + background-position: center; +} + /* Detection Manager Icon - radar/search icon */ .detection-icon { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z'/%3E%3Cpath d='M12 8c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm-1 5h2v2h-2v-2zm0-2h2v1h-2v-1zm0-1h2v1h-2v-1z'/%3E%3C/svg%3E"); diff --git a/UIMod/onboard_bundled/assets/css/home.css b/UIMod/onboard_bundled/assets/css/home.css index 3b0862a5..2bc78208 100644 --- a/UIMod/onboard_bundled/assets/css/home.css +++ b/UIMod/onboard_bundled/assets/css/home.css @@ -432,7 +432,14 @@ opacity: 0.8; } -.restore-btn { +.backup-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.restore-btn, +.download-btn { padding: 10px 18px; background-color: rgba(0, 255, 171, 0.1); color: var(--text-bright); @@ -445,7 +452,8 @@ text-transform: uppercase; } -.restore-btn:hover { +.restore-btn:hover, +.download-btn:hover { background-color: var(--primary); color: #000; } diff --git a/UIMod/onboard_bundled/assets/icons/back.webp b/UIMod/onboard_bundled/assets/icons/back.webp new file mode 100644 index 00000000..312a3456 Binary files /dev/null and b/UIMod/onboard_bundled/assets/icons/back.webp differ diff --git a/UIMod/onboard_bundled/assets/js/console-manager.js b/UIMod/onboard_bundled/assets/js/console-manager.js index 87baab40..4c65fdd2 100644 --- a/UIMod/onboard_bundled/assets/js/console-manager.js +++ b/UIMod/onboard_bundled/assets/js/console-manager.js @@ -98,6 +98,7 @@ function handleConsole() { "Balancing gas mixtures... kaboom imminent, run you fool!", "Spoiler: object reference not set to an instance of an object, lol", "Fun fact: SSUI was originally a simple powershell script", + "Fun fact: This dashboard was supposed to look like the retro computer from Stationeers. We tried, ok?", "Convincing server that 'out of memory' is just a state of mind.", "Moo, Moo! I'm a cow!", "Welcome home, Sir!" diff --git a/UIMod/onboard_bundled/assets/js/server-api.js b/UIMod/onboard_bundled/assets/js/server-api.js index 2b1b5a7d..38d91e8d 100644 --- a/UIMod/onboard_bundled/assets/js/server-api.js +++ b/UIMod/onboard_bundled/assets/js/server-api.js @@ -76,6 +76,7 @@ function fetchBackups() { const backupType = "Dotsave" const fileName = "Backup Index: " + backup.Index; const formattedDate = "Created: " + new Date(backup.SaveTime).toLocaleString(); + const isDotsave = backupType === "Dotsave"; li.innerHTML = `
@@ -85,7 +86,10 @@ function fetchBackups() {
${formattedDate}
- +
+ ${isDotsave ? `` : ''} + +
`; backupList.appendChild(li); @@ -208,6 +212,48 @@ function restoreBackup(index) { .catch(err => console.error(`Failed to restore backup ${index}:`, err)); } +function downloadBackup(index) { + const status = document.getElementById('status'); + status.hidden = false; + typeTextWithCallback(status, 'Preparing download...', 20, () => {}); + + fetch('/api/v2/backups/download', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ index: index }) + }) + .then(response => { + if (!response.ok) { + return response.json().then(err => { throw new Error(err.error || 'Download failed'); }); + } + const disposition = response.headers.get('Content-Disposition'); + let filename = `backup_${index}.save`; + if (disposition) { + const match = disposition.match(/filename="(.+)"/); + if (match) filename = match[1]; + } + return response.blob().then(blob => ({ blob, filename })); + }) + .then(({ blob, filename }) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); + status.hidden = true; + }) + .catch(err => { + console.error(`Failed to download backup ${index}:`, err); + showPopup('error', 'Download failed: ' + err.message); + status.hidden = true; + }); +} + function pollRecurringTasks() { window.gamserverstate = false; diff --git a/UIMod/onboard_bundled/localization/de-DE.json b/UIMod/onboard_bundled/localization/de-DE.json index bdca48cf..90f922d9 100644 --- a/UIMod/onboard_bundled/localization/de-DE.json +++ b/UIMod/onboard_bundled/localization/de-DE.json @@ -22,6 +22,8 @@ "UIText_UpdateFailed": "Aktualisierung fehlgeschlagen. Bitte versuche es später erneut." }, "config": { + "UIText_BackToDashboard": "Zurück zum Dashboard", + "UIText_ConfigHeadline": "Einstellungen", "UIText_ServerConfig": "Server Konfiguration", "UIText_DiscordIntegration": "Discord-Integration", "UIText_SLPModIntegration": "Launchpad Mods", @@ -32,6 +34,7 @@ "UIText_BasicSettings": "Basis", "UIText_NetworkSettings": "Netzwerk", "UIText_AdvancedSettings": "Erweitert", + "UIText_TerrainSettings": "Weltgenerierung", "basic": { "UIText_BasicServerSettings": "Grundlegende Servereinstellungen", "UIText_ServerName": "Servername", @@ -82,6 +85,8 @@ "UIText_ServerExePathInfo2": "Aus Sicherheitsgründen nicht über UI editierbar, aber manuell in config.json änderbar.", "UIText_AdditionalParams": "Zusätzliche Parameter", "UIText_AdditionalParamsInfo": "Format: EigenParam1 Wert1 EigenParam2 Wert2", + "UIText_ShowExpertSettings": "Experteneinstellungen anzeigen", + "UIText_ShowExpertSettingsInfo": "Die Experteneinstellungen anzeigen, um auf spezielle Konfigurationsoptionen zuzugreifen. Die Konfiguration muss nach Änderung dieser Einstellung gespeichert werden, damit die Änderungen wirksam werden.", "UIText_AutoRestartServerTimer": "Geplanter Gameserver Neustart", "UIText_AutoRestartServerTimerInfo": "

Zeitraum in Minuten oder Zeitformat (z. B. 15:04 oder 03:04 Uhr) um einen automatischen Neustart des Spielservers zu planen. 0 = deaktiviert, 1440 = 24 Stunden usw. Vor dem Neustart wird im Spiel die Meldung „Achtung, der Server wird in 30/20/10/5 Sekunden neu gestartet!“ angezeigt .

", "UIText_GameBranch": "Spiel Branch", @@ -117,6 +122,10 @@ "UIText_AdminCommandChannelInfo": "Channel für Admin-Befehle", "UIText_ControlPanelChannel": "Kontrollpanel Channel", "UIText_ControlPanelChannelInfo": "Channel für Kontrollpanel", + "UIText_ServerInfoPanelChannel": "Server-Info-Panel Channel", + "UIText_ServerInfoPanelChannelInfo": "Optionaler Channel, der den Servernamen für Benutzer (nicht Admins) anzeigt und Benutzern die Möglichkeit gibt, das aktuelle Server-Passwort abzurufen.", + "UIText_RotateServerPassword": "Server-Passwort rotieren", + "UIText_RotateServerPasswordInfo": "Rotiert das Server-Passwort automatisch bei jedem Serverstart. Spieler können das aktuelle Passwort über einen Button im \"Server-Info-Panel Channel\" abrufen.", "UIText_StatusChannel": "Statuskanal", "UIText_StatusChannelInfo": "Server Status Updates", "UIText_ConnectionListChannel": "Verbindungslisten Channel", @@ -157,8 +166,7 @@ "UIText_SLP_UpdateWorkshopModsDesc": "Aktualisiert alle installierten Workshop-Mods auf die neueste Version. Dieser Vorgang kann je nach Anzahl der Mods einige Zeit dauern.", "UIText_SLP_UpdateButton": "Workshop-Mods aktualisieren", "UIText_SLP_InstalledMods": "Installierte Mods" - }, - "UIText_TerrainSettings": "Weltgenerierung" + } }, "setup": { "UIText_FooterText": "Hilfe benötigt? Schaue ins Stationeers Server UI Github Wiki.", diff --git a/UIMod/onboard_bundled/localization/en-US.json b/UIMod/onboard_bundled/localization/en-US.json index 7cf978d3..771215f7 100644 --- a/UIMod/onboard_bundled/localization/en-US.json +++ b/UIMod/onboard_bundled/localization/en-US.json @@ -22,6 +22,8 @@ "UIText_UpdateFailed": "Update failed. Please try again later." }, "config": { + "UIText_BackToDashboard": "Back to Dashboard", + "UIText_ConfigHeadline": "Settings & Configuration", "UIText_ServerConfig": "Server Configuration", "UIText_DiscordIntegration": "Discord Integration", "UIText_SLPModIntegration": "Launchpad Mods", @@ -33,6 +35,11 @@ "UIText_NetworkSettings": "Network Settings", "UIText_AdvancedSettings": "Advanced Features", "UIText_TerrainSettings": "World generation", + "tabDescriptions": { + "UIText_ServerConfigTabDescription": "Here you can configure settings related to your gameserver like basic settings like server name and password, network settings like ports and UPnP, and SSUI settings like automatic SSUI updates, scheduled gameserver restarts and more.", + "UIText_DiscordIntegrationTabDescription": "Configure the Discord integration for real-time server status updates, remote management, player connection tracking, and community engagement. Learn more about the Discord integration and setup instructions in the Wiki.", + "UIText_SLPModIntegrationTabDescription": "Easily manage your Stationeers mods with our Stationeers Launch Pad (SLP) integration. Learn more about the SLP integration in the Wiki." + }, "basic": { "UIText_BasicServerSettings": "Basic Server Settings", "UIText_ServerName": "Server Name", @@ -83,6 +90,8 @@ "UIText_ServerExePathInfo2": "Not editable from the UI for security reasons, but you can edit it manually in the config.json file.", "UIText_AdditionalParams": "Additional Parameters", "UIText_AdditionalParamsInfo": "Format: CustomParam1 Value1 CustomParam2 Value2", + "UIText_ShowExpertSettings": "Show Expert Settings", + "UIText_ShowExpertSettingsInfo": "Show the Expert Settings and access special configuration options below. The config needs to be saved after changing this setting for the changes to take effect.", "UIText_AutoRestartServerTimer": "Scheduled Gameserver Restart", "UIText_AutoRestartServerTimerInfo": "

Timeframe in minutes or time format (e.g., 15:04 or 03:04PM) to schedule an automatic gameserver restart. 0 = disabled, 1440 = 24 hours, etc. You will see 'Attention, server is restarting in 30/20/10/5 seconds!' messages ingame before the restart.

", "UIText_GameBranch": "Game Branch", @@ -117,6 +126,10 @@ "UIText_AdminCommandChannelInfo": "Channel for admin commands", "UIText_ControlPanelChannel": "Control Panel Channel", "UIText_ControlPanelChannelInfo": "Channel for control panel", + "UIText_ServerInfoPanelChannel": "Server Info Panel Channel", + "UIText_ServerInfoPanelChannelInfo": "Optional channel which shows server name for users (not admins) and gives users the option to retrieve the current server password.", + "UIText_RotateServerPassword": "Rotate Server Password", + "UIText_RotateServerPasswordInfo": "Automatically rotates the server password on each server start. Players can get the current password via a button in the \"Server Info Panel Channel\"", "UIText_StatusChannel": "Status Channel", "UIText_StatusChannelInfo": "Server status updates", "UIText_ConnectionListChannel": "Connection List Channel", diff --git a/UIMod/onboard_bundled/localization/sv-SE.json b/UIMod/onboard_bundled/localization/sv-SE.json index 63865092..57e4d339 100644 --- a/UIMod/onboard_bundled/localization/sv-SE.json +++ b/UIMod/onboard_bundled/localization/sv-SE.json @@ -22,6 +22,8 @@ "UIText_UpdateFailed": "Uppdateringen misslyckades. Försök igen senare." }, "config": { + "UIText_BackToDashboard": "Tillbaka till dashboard", + "UIText_ConfigHeadline": "Inställningar", "UIText_ServerConfig": "Konfiguration", "UIText_DiscordIntegration": "Discord-integration", "UIText_SLPModIntegration": "Launchpad Mods", @@ -32,6 +34,7 @@ "UIText_BasicSettings": "Grund", "UIText_NetworkSettings": "Nätverk", "UIText_AdvancedSettings": "Avancerad", + "UIText_TerrainSettings": "Världsgenerering", "basic": { "UIText_BasicServerSettings": "Grundläggande serverinställningar", "UIText_ServerName": "Servernamn", @@ -82,6 +85,8 @@ "UIText_ServerExePathInfo2": "Kan inte redigeras från gränssnittet av säkerhetsskäl, men du kan ändra det manuellt i config.json-filen.", "UIText_AdditionalParams": "Ytterligare parametrar", "UIText_AdditionalParamsInfo": "Format: AnpassadParam1 Värde1 AnpassadParam2 Värde2", + "UIText_ShowExpertSettings": "Visa expertinställningar", + "UIText_ShowExpertSettingsInfo": "Visa expertinställningarna för att få tillgång till speciella konfigurationsalternativ nedan. Konfigurationen måste sparas efter att denna inställning ändrats för att ändringarna ska träda i kraft.", "UIText_AutoRestartServerTimer": "Schemalagd spelserveromstart", "UIText_AutoRestartServerTimerInfo": "

Tidsram i minuter eller tidsformat (t.ex. 15:04 eller 03:04PM) för att schemalägga en automatisk omstart av spelservern. 0 = inaktiverad, 1440 = 24 timmar, etc. Du kommer att se meddelandet 'Observera, server startar om 30/20/10/5 sekunder!' i spelet före omstarten.

", "UIText_GameBranch": "Spel-Branch", @@ -115,6 +120,10 @@ "UIText_AdminCommandChannelInfo": "Kanal för admin-kommandon", "UIText_ControlPanelChannel": "Kontrollpanelkanal", "UIText_ControlPanelChannelInfo": "Kanal för kontrollpanel", + "UIText_ServerInfoPanelChannel": "Serverinfo-panelkanal", + "UIText_ServerInfoPanelChannelInfo": "Valfri kanal som visar servernamn för användare (inte administratörer) och ger användare möjligheten att hämta det aktuella serverlösenordet.", + "UIText_RotateServerPassword": "Rotera serverlösenord", + "UIText_RotateServerPasswordInfo": "Roterar automatiskt serverlösenordet vid varje serverstart. Spelare kan hämta det aktuella lösenordet via en knapp i \"Serverinfo-panelkanalen\".", "UIText_StatusChannel": "Statuskanal", "UIText_StatusChannelInfo": "Uppdateringar om serverstatus", "UIText_ConnectionListChannel": "Anslutningslistkanal", @@ -155,8 +164,7 @@ "UIText_SLP_UpdateWorkshopModsDesc": "Uppdatera alla installerade workshop-mods till deras senaste versioner. Denna process kan ta lite tid beroende på antalet mods.", "UIText_SLP_UpdateButton": "Uppdatera Workshop-mods", "UIText_SLP_InstalledMods": "Installerade Mods" - }, - "UIText_TerrainSettings": "Världsgenerering" + } }, "setup": { "UIText_FooterText": "Behöver du hjälp? Kolla Stationeers Server UI Github Wiki.", diff --git a/UIMod/onboard_bundled/ui/config.html b/UIMod/onboard_bundled/ui/config.html index 206182a6..700062a3 100644 --- a/UIMod/onboard_bundled/ui/config.html +++ b/UIMod/onboard_bundled/ui/config.html @@ -36,10 +36,14 @@

-

{{.UIText_ServerConfig}}

+

{{.UIText_ConfigHeadline}}

+
- - +
+ + +
{{.UIText_ShowExpertSettingsInfo}}
+
+ + {{if eq .ShowExpertSettingsTrueSelected "selected"}} +

Expert Settings below - only change if you know what you are doing

+
+ +
+ + +
Enable debug mode for verbose logging (also enables pprof server)
+
+ +
+ + +
Log level: 10=Debug, 20=Info (default), 30=Warn, 40=Error
+
+ +
+ + +
Show verbose mono/unity logs from game server in console
+
+ +
+ + +
Port for the SSUI web interface (default: 8443). Requires restart.
+
+ +
+ + +
Enable the SSUICLI interactive console
+
+ +
+ + +
Enable Stationeers Server Command Manager integration (Linux)
+
+ +
+ + +
Override the IP address advertised to Steam. Leave empty for auto-detection.
+
+ +
+ + +
Enable automatic SSUI update checks
+
+ +
+ + +
Include beta/prerelease versions in update checks
+
+ +
+ + +
Allow automatic major version upgrades (may have breaking changes)
+
+ +
+ + +
Automatically update StationeersLaunchPad
+
+ +
+ + +
Enable authentication for the web interface
+
+ +
+ + +
JWT token lifetime in minutes (default: 1440 = 24 hours)
+
+
+ {{end}}
@@ -344,6 +465,7 @@

{{.UIText_TerrainSettingsHeader}}

+
+
+ + +
{{.UIText_ServerInfoPanelChannelInfo}}
+
+ +
+ + +
{{.UIText_RotateServerPasswordInfo}}
+
+
@@ -525,7 +663,6 @@

{{.UIText_SLP_InstalledMods}}

-

Custom Detection Manager

Custom detections allow you to create custom patterns for event detection. These patterns can be used to detect specific events in the server log and send alerts to the "Status" channel in Discord and will be shown in the Events tab on the main dashboard.

To create a custom detection, you can either define a regex pattern or diff --git a/go.mod b/go.mod index 679eae5c..2967a033 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,36 @@ go 1.25.0 require ( github.com/bwmarrin/discordgo v0.28.1 + github.com/charmbracelet/bubbles v0.21.1 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/fsnotify/fsnotify v1.7.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/jacksonthemaster/discordrichpresence v1.1.0 + github.com/mattn/go-isatty v0.0.20 golang.org/x/crypto v0.37.0 - golang.org/x/sys v0.35.0 + golang.org/x/sys v0.38.0 ) -require github.com/gorilla/websocket v1.5.3 // indirect +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.5 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/go.sum b/go.sum index 870eb11e..460aa971 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,33 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/charmbracelet/bubbles v0.21.1 h1:nj0decPiixaZeL9diI4uzzQTkkz1kYY8+jgzCZXSmW0= +github.com/charmbracelet/bubbles v0.21.1/go.mod h1:HHvIYRCpbkCJw2yo0vNX1O5loCwSr9/mWS8GYSg50Sk= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.5 h1:NBWeBpj/lJPE3Q5l+Lusa4+mH6v7487OP8K0r1IhRg4= +github.com/charmbracelet/x/ansi v0.11.5/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -11,13 +39,37 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jacksonthemaster/discordrichpresence v1.1.0 h1:4UmompqAKyEpspR/Z0LFj6vdSWJf6FZQ/J787KvdxMM= github.com/jacksonthemaster/discordrichpresence v1.1.0/go.mod h1:XA0SB8bsEc5oJCQcXjC78BfNBj9FoLFA4ysfevkUjHE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/server.go b/server.go index 4a079f17..4bc51a82 100644 --- a/server.go +++ b/server.go @@ -49,10 +49,10 @@ func main() { wg.Wait() logger.Main.Debug("Initializing Backend...") loader.InitBackend() - logger.Main.Debug("Initializing after start tasks...") - loader.AfterStartComplete() logger.Main.Debug("Starting webserver...") web.StartWebServer(&wg) + logger.Main.Debug("Initializing after start tasks...") + loader.AfterStartComplete() logger.Main.Debug("Initializing SSUICLI...") cli.StartConsole(&wg) wg.Wait() diff --git a/src/cli/commands.go b/src/cli/commands.go index c00b426f..7c7b10ba 100644 --- a/src/cli/commands.go +++ b/src/cli/commands.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/cli/dashboard" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" @@ -23,6 +24,7 @@ import ( // init registers default cli commands and their aliases. func init() { RegisterCommand("help", helpCommand, "h") + RegisterCommand("dashboard", dashboardCommand, "dash", "d") RegisterCommand("reloadbackend", WrapNoReturn(loader.ReloadBackend), "rlb", "rb", "r") RegisterCommand("reloadconfig", WrapNoReturn(loader.ReloadConfig), "rlc", "rc") RegisterCommand("restartbackend", WrapNoReturn(loader.RestartBackend), "rsb") @@ -44,6 +46,16 @@ func init() { RegisterCommand("downloadworkshopitemtest", WrapNoReturn(downloadWorkshopItemTest), "dwmodcon") } +// dashboardCommand launches the interactive terminal dashboard +func dashboardCommand(args []string) error { + logger.Core.Info("Launching interactive dashboard... (press 'q' or 'esc' to exit)") + if err := dashboard.Run(); err != nil { + return fmt.Errorf("dashboard error: %w", err) + } + logger.Core.Info("Dashboard closed, returning to SSUICLI") + return nil +} + // COMMAND HANDLERS WITH COMMANDS USEFUL FOR USERS func downloadWorkshopUpdates() { diff --git a/src/cli/dashboard/config_panel.go b/src/cli/dashboard/config_panel.go new file mode 100644 index 00000000..a0993c50 --- /dev/null +++ b/src/cli/dashboard/config_panel.go @@ -0,0 +1,305 @@ +package dashboard + +import ( + "fmt" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/core/loader" + "github.com/charmbracelet/lipgloss" +) + +// Config Items Definition + +// buildConfigItems creates a list of editable config items grouped by section +func buildConfigItems() []ConfigItem { + return []ConfigItem{ + // ───────────────────────────────────────────────────────────────────── + // Basic Settings + // ───────────────────────────────────────────────────────────────────── + {Key: "ServerName", Label: "Server Name", Value: config.GetServerName(), Type: "string", Section: ConfigSectionBasic, Description: "Display name for your server"}, + {Key: "SaveName", Label: "Save Name", Value: config.GetSaveName(), Type: "string", Section: ConfigSectionBasic, Description: "World save file name"}, + {Key: "ServerMaxPlayers", Label: "Max Players", Value: config.GetServerMaxPlayers(), Type: "string", Section: ConfigSectionBasic, Description: "Maximum concurrent players"}, + {Key: "ServerPassword", Label: "Server Password", Value: config.GetServerPassword(), Type: "password", Section: ConfigSectionBasic, Description: "Password to join server (blank = none)"}, + {Key: "ServerVisible", Label: "Server Visible", Value: boolToStr(config.GetServerVisible()), Type: "bool", Section: ConfigSectionBasic, Description: "List server publicly"}, + {Key: "AutoSave", Label: "Auto-Save", Value: boolToStr(config.GetAutoSave()), Type: "bool", Section: ConfigSectionBasic, Description: "Enable automatic world saves"}, + {Key: "SaveInterval", Label: "Save Interval", Value: config.GetSaveInterval(), Type: "string", Section: ConfigSectionBasic, Description: "Time between saves (e.g., 300)"}, + + // ───────────────────────────────────────────────────────────────────── + // World Generation Settings + // ───────────────────────────────────────────────────────────────────── + {Key: "WorldID", Label: "World ID", Value: config.GetWorldID(), Type: "string", Section: ConfigSectionWorldGen, Description: "World seed identifier"}, + {Key: "Difficulty", Label: "Difficulty", Value: config.GetDifficulty(), Type: "string", Section: ConfigSectionWorldGen, Description: "Game difficulty setting"}, + {Key: "StartCondition", Label: "Start Condition", Value: config.GetStartCondition(), Type: "string", Section: ConfigSectionWorldGen, Description: "Initial spawn condition"}, + {Key: "StartLocation", Label: "Start Location", Value: config.GetStartLocation(), Type: "string", Section: ConfigSectionWorldGen, Description: "Starting world location"}, + + // ───────────────────────────────────────────────────────────────────── + // Network Settings + // ───────────────────────────────────────────────────────────────────── + {Key: "GamePort", Label: "Game Port", Value: config.GetGamePort(), Type: "string", Section: ConfigSectionNetwork, Description: "Main game connection port"}, + {Key: "UpdatePort", Label: "Update Port", Value: config.GetUpdatePort(), Type: "string", Section: ConfigSectionNetwork, Description: "Server query port"}, + {Key: "UPNPEnabled", Label: "UPnP Enabled", Value: boolToStr(config.GetUPNPEnabled()), Type: "bool", Section: ConfigSectionNetwork, Description: "Automatic port forwarding"}, + {Key: "LocalIpAddress", Label: "Local IP", Value: config.GetLocalIpAddress(), Type: "string", Section: ConfigSectionNetwork, Description: "Bind to specific IP (blank = auto)"}, + {Key: "StartLocalHost", Label: "Start LocalHost", Value: boolToStr(config.GetStartLocalHost()), Type: "bool", Section: ConfigSectionNetwork, Description: "Start in localhost mode"}, + {Key: "UseSteamP2P", Label: "Use Steam P2P", Value: boolToStr(config.GetUseSteamP2P()), Type: "bool", Section: ConfigSectionNetwork, Description: "Use Steam peer-to-peer networking"}, + + // ───────────────────────────────────────────────────────────────────── + // Advanced Settings + // ───────────────────────────────────────────────────────────────────── + {Key: "AutoStartServerOnStartup", Label: "Auto-Start Server", Value: boolToStr(config.GetAutoStartServerOnStartup()), Type: "bool", Section: ConfigSectionAdvanced, Description: "Start game server when SSUI starts"}, + {Key: "AutoRestartServerTimer", Label: "Auto-Restart Timer", Value: config.GetAutoRestartServerTimer(), Type: "string", Section: ConfigSectionAdvanced, Description: "Auto-restart interval (0 = disabled)"}, + {Key: "AutoPauseServer", Label: "Auto-Pause", Value: boolToStr(config.GetAutoPauseServer()), Type: "bool", Section: ConfigSectionAdvanced, Description: "Pause when no players"}, + {Key: "IsSSCMEnabled", Label: "SSCM/BepInEx", Value: boolToStr(config.GetIsSSCMEnabled()), Type: "bool", Section: ConfigSectionAdvanced, Description: "Enable mod support"}, + {Key: "Branch", Label: "Game Branch", Value: config.GetGameBranch(), Type: "string", Section: ConfigSectionAdvanced, Description: "Steam branch (public, beta, etc.)"}, + {Key: "LogClutterToConsole", Label: "Log Clutter", Value: boolToStr(config.GetLogClutterToConsole()), Type: "bool", Section: ConfigSectionAdvanced, Description: "Show verbose logs in console"}, + } +} + +// Config Saving + +// saveConfigItem saves a single config item using the appropriate setter +func saveConfigItem(item ConfigItem) error { + switch item.Key { + // Basic Settings + case "ServerName": + return config.SetServerName(item.Value) + case "SaveName": + return config.SetSaveName(item.Value) + case "ServerMaxPlayers": + return config.SetServerMaxPlayers(item.Value) + case "ServerPassword": + return config.SetServerPassword(item.Value) + case "ServerVisible": + return config.SetServerVisible(strToBool(item.Value)) + case "AutoSave": + return config.SetAutoSave(strToBool(item.Value)) + case "SaveInterval": + return config.SetSaveInterval(item.Value) + + // World Generation Settings + case "WorldID": + return config.SetWorldID(item.Value) + case "Difficulty": + return config.SetDifficulty(item.Value) + case "StartCondition": + return config.SetStartCondition(item.Value) + case "StartLocation": + return config.SetStartLocation(item.Value) + + // Network Settings + case "GamePort": + return config.SetGamePort(item.Value) + case "UpdatePort": + return config.SetUpdatePort(item.Value) + case "UPNPEnabled": + return config.SetUPNPEnabled(strToBool(item.Value)) + case "LocalIpAddress": + return config.SetLocalIpAddress(item.Value) + case "StartLocalHost": + return config.SetStartLocalHost(strToBool(item.Value)) + case "UseSteamP2P": + return config.SetUseSteamP2P(strToBool(item.Value)) + + // Advanced Settings + case "AutoStartServerOnStartup": + return config.SetAutoStartServerOnStartup(strToBool(item.Value)) + case "AutoRestartServerTimer": + return config.SetAutoRestartServerTimer(item.Value) + case "AutoPauseServer": + return config.SetAutoPauseServer(strToBool(item.Value)) + case "IsSSCMEnabled": + return config.SetIsSSCMEnabled(strToBool(item.Value)) + case "Branch": + return config.SetGameBranch(item.Value) + case "LogClutterToConsole": + return config.SetLogClutterToConsole(strToBool(item.Value)) + + } + return nil // Attention: saveConfigItem silently succeeds for unknown keys +} + +// saveAllConfigChanges saves all config items and reloads the backend +func saveAllConfigChanges(items []ConfigItem) error { + for _, item := range items { + if err := saveConfigItem(item); err != nil { + return fmt.Errorf("failed to save %s: %w", item.Key, err) + } + } + loader.ReloadBackend() + return nil +} + +// Panel Rendering + +func (m Model) renderConfigPanel() string { + var lines []string + + // Header with save status + headerLine := RenderSectionTitle("Configuration Editor") + if m.configHasChanges { + headerLine += " " + lipgloss.NewStyle().Foreground(Yellow).Bold(true).Render("• Unsaved Changes, Ctrl+S to Save") + } + if m.configStatusMsg != "" && m.configStatusTick > 0 { + headerLine += " " + lipgloss.NewStyle().Foreground(Green).Render(m.configStatusMsg) + } + lines = append(lines, headerLine) + lines = append(lines, "") + + lines = append(lines, MutedStyle.Render(" Use ↑↓ to navigate, Enter to edit, Space to toggle, Ctrl+S to save")) + lines = append(lines, "") + + navIndex := 0 + for section := range ConfigSectionCount { + sectionName := configSectionNames[section] + isOpen := m.configSectionOpen[section] + + expandIcon := "▶" + if isOpen { + expandIcon = "▼" + } + + sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(PurpleLight) + sectionHeader := sectionStyle.Render(expandIcon + " " + sectionName) + + if !m.configEditing && m.configSelectedIndex == navIndex && m.activePanel == PanelConfig { + sectionHeader = lipgloss.NewStyle(). + Background(Purple). + Foreground(White). + Bold(true). + Render(expandIcon + " " + sectionName) + } + + lines = append(lines, sectionHeader) + navIndex++ + + if isOpen { + sectionItems := m.getItemsForSection(section) + for _, item := range sectionItems { + line := m.renderConfigItem(item, navIndex) + lines = append(lines, line) + navIndex++ + } + } + lines = append(lines, "") + } + + content := lipgloss.JoinVertical(lipgloss.Left, lines...) + return m.renderPanelContainer(content) +} + +func (m Model) renderConfigItem(item ConfigItem, index int) string { + isSelected := !m.configEditing && m.configSelectedIndex == index && m.activePanel == PanelConfig + isEditing := m.configEditing && m.configSelectedIndex == index && m.activePanel == PanelConfig + + labelStyle := lipgloss.NewStyle().Width(24).Foreground(Gray400) + label := labelStyle.Render(" " + item.Label + ":") + + var valueDisplay string + + if isEditing { + editStyle := lipgloss.NewStyle(). + Background(Gray700). + Foreground(White). + Padding(0, 1) + + displayValue := m.configEditValue + if len(displayValue) == 0 { + displayValue = " " + } + valueDisplay = editStyle.Render(displayValue + "▌") + } else if item.Type == "bool" { + if strToBool(item.Value) { + valueDisplay = lipgloss.NewStyle().Foreground(Green).Render(CheckMark + " true") + } else { + valueDisplay = lipgloss.NewStyle().Foreground(Red).Render(CrossMark + " false") + } + } else if item.Type == "password" && item.Value != "" { + valueDisplay = MutedStyle.Render("••••••••") + } else if item.Value == "" { + valueDisplay = MutedStyle.Render("(not set)") + } else { + valueDisplay = ValueStyle.Render(item.Value) + } + + if isSelected { + label = lipgloss.NewStyle(). + Width(24). + Background(Purple). + Foreground(White). + Render(" ▸ " + item.Label + ":") + } + + return label + " " + valueDisplay +} + +func (m Model) getItemsForSection(section ConfigSection) []ConfigItem { + var items []ConfigItem + for _, item := range m.configItems { + if item.Section == section { + items = append(items, item) + } + } + return items +} + +func (m Model) getTotalConfigItems() int { + count := 0 + for section := range ConfigSectionCount { + count++ // Section header + if m.configSectionOpen[section] { + count += len(m.getItemsForSection(section)) + } + } + return count +} + +// getConfigItemAtIndex returns a config item at given nav index +func (m Model) getConfigItemAtIndex(index int) (*ConfigItem, bool, ConfigSection) { + currentIndex := 0 + for section := range ConfigSectionCount { + // Check if we're on the section header + if currentIndex == index { + return nil, true, section // It's a section header + } + currentIndex++ + + if m.configSectionOpen[section] { + items := m.getItemsForSection(section) + for i := range items { + if currentIndex == index { + return &m.configItems[m.getGlobalItemIndex(section, i)], false, section + } + currentIndex++ + } + } + } + return nil, false, 0 +} + +// getGlobalItemIndex converts section-local index to global configItems index +func (m Model) getGlobalItemIndex(section ConfigSection, localIndex int) int { + globalIndex := 0 + for _, item := range m.configItems { + if item.Section == section { + if localIndex == 0 { + return globalIndex + } + localIndex-- + } + globalIndex++ + } + return -1 +} + +// Helpers + +func boolToStr(b bool) string { + if b { + return "true" + } + return "false" +} + +func strToBool(s string) bool { + return strings.ToLower(s) == "true" +} diff --git a/src/cli/dashboard/dashboard.go b/src/cli/dashboard/dashboard.go new file mode 100644 index 00000000..fa3dc846 --- /dev/null +++ b/src/cli/dashboard/dashboard.go @@ -0,0 +1,106 @@ +package dashboard + +import ( + "fmt" + "os" + "sync" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + tea "github.com/charmbracelet/bubbletea" + "github.com/mattn/go-isatty" +) + +func init() { + // Register hooks with the logger to enable log capture during dashboard mode. + // (logger doesn't import dashboard, dashboard imports logger). + // See note in logger/logger.go (bottom) + logger.RegisterDashboardHooks(IsDashboardActive, CaptureLog) +} + +var ( + // dashboardActive tracks whether the dashboard is currently running. + // Used by the logger to suppress console output during dashboard mode. + dashboardActive bool + dashboardMu sync.RWMutex + + // logBuffer holds SSUI log messages captured while dashboard is active. + logBuffer []string + logBufferMu sync.Mutex + maxLogLines = 200 +) + +// IsDashboardActive returns whether the dashboard is currently running. +// The logger can use this to suppress direct console writes. +func IsDashboardActive() bool { + dashboardMu.RLock() + defer dashboardMu.RUnlock() + return dashboardActive +} + +// setDashboardActive sets the dashboard active state. +func setDashboardActive(active bool) { + dashboardMu.Lock() + defer dashboardMu.Unlock() + dashboardActive = active +} + +// CaptureLog captures a Backend log line for display in the dashboard. +// Called by the logger when dashboard is active. +func CaptureLog(line string) { + logBufferMu.Lock() + defer logBufferMu.Unlock() + logBuffer = append(logBuffer, line) + if len(logBuffer) > maxLogLines { + logBuffer = logBuffer[1:] + } +} + +// GetLogBuffer returns a copy of the current Backend log buffer. (Dashboard-Local) +func GetLogBuffer() []string { + logBufferMu.Lock() + defer logBufferMu.Unlock() + result := make([]string, len(logBuffer)) + copy(result, logBuffer) + return result +} + +// ClearLogBuffer clears the Backend log buffer. (Dashboard-Local) +func ClearLogBuffer() { + logBufferMu.Lock() + defer logBufferMu.Unlock() + logBuffer = nil +} + +// IsInteractiveTerminal checks if stdin/stdout are connected to a terminal. +// Returns false for Docker, systemd, or piped/redirected IO. +func IsInteractiveTerminal() bool { + return isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd()) +} + +// Run takes over the terminal until the user exits. +func Run() error { + + setDashboardActive(true) + ClearLogBuffer() + + m := NewModel() + + p := tea.NewProgram( + m, + tea.WithAltScreen(), // Use alternate screen buffer (preserves shell history) + ) + + finalModel, err := p.Run() + + setDashboardActive(false) + + if err != nil { + return fmt.Errorf("dashboard error: %w", err) + } + + if m, ok := finalModel.(Model); ok && m.err != nil { + return m.err + } + + return nil +} diff --git a/src/cli/dashboard/model.go b/src/cli/dashboard/model.go new file mode 100644 index 00000000..cf390308 --- /dev/null +++ b/src/cli/dashboard/model.go @@ -0,0 +1,408 @@ +package dashboard + +import ( + "fmt" + "strconv" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" +) + +// Panel System + +type Panel int // holds current active panel +type ConfigSection int + +// ConfigItem represents a single editable config item +type ConfigItem struct { + Key string + Label string + Value string + Type string // "string", "bool", "int" + Section ConfigSection + Description string +} + +const ( + PanelStatus Panel = iota // Server status overview (default) + PanelSSUILog // SSUI backend logs + PanelPlayers // Connected players list + PanelConfig // Configuration editor + PanelCount // Used for cycling +) + +var panelNames = map[Panel]string{ + PanelStatus: "Status", + PanelSSUILog: "Logs", + PanelPlayers: "Players", + PanelConfig: "Config", +} + +var panelIcons = map[Panel]string{ + PanelStatus: "📊", + PanelSSUILog: "📜", + PanelPlayers: "👥", + PanelConfig: "⚙", +} + +const ( + ConfigSectionBasic ConfigSection = iota + ConfigSectionNetwork + ConfigSectionAdvanced + ConfigSectionWorldGen + ConfigSectionCount +) + +var configSectionNames = map[ConfigSection]string{ + ConfigSectionBasic: "Basic Settings", + ConfigSectionNetwork: "Network Settings", + ConfigSectionAdvanced: "Advanced Settings", + ConfigSectionWorldGen: "World Generation", +} + +// Model - Dashboard State + +// Model represents the dashboard state +type Model struct { + + // Window and Layout + + width int + height int + + // Navigation + + activePanel Panel + + // Components + + logViewport viewport.Model // For SSUI backend logs + help help.Model + keys keyMap + + // Server State + + serverRunning bool + serverUptime time.Duration + serverStartTime time.Time + serverName string + saveName string + worldID string + gamePort string + updatePort string + maxPlayers int + connectedPlayers map[string]string // SteamID -> PlayerName + + // Game Info + + gameVersion string + gameBranch string + buildID string + + // SSUI Info + + ssuiVersion string + goRuntime string + isDocker bool + + // Feature Flags + + discordEnabled bool + autoSaveEnabled bool + autoRestartEnabled bool + autoRestartTimer string + upnpEnabled bool + authEnabled bool + bepInExEnabled bool + saveInterval string + autoStartEnabled bool + + // Backup Info + + backupKeepLastN int + backupDailyFor int + backupWeeklyFor int + backupMonthlyFor int + + // Config Panel State + + configItems []ConfigItem // All config items + configSectionOpen map[ConfigSection]bool // Which sections are expanded + configSelectedIndex int // Currently selected config item + configEditing bool // Whether we're editing a value + configEditValue string // Current edit buffer + configCursorPos int // Cursor position in edit + configHasChanges bool // Unsaved changes flag + configStatusMsg string // Status message (e.g., "Saved!") + configStatusTick int // Countdown for status message + + // Internal State + + err error + quitting bool + startTime time.Time + tickCount int + lastRefresh time.Time + showFullHelp bool +} + +// Key Bindings + +type keyMap struct { + Tab key.Binding + ShiftTab key.Binding + Up key.Binding + Down key.Binding + PageUp key.Binding + PageDown key.Binding + Home key.Binding + End key.Binding + Start key.Binding + Stop key.Binding + Refresh key.Binding + Help key.Binding + Quit key.Binding + Enter key.Binding // For config editing + Space key.Binding // Toggle sections/bools + Save key.Binding // Save config changes +} + +// ShortHelp returns the short help text (displayed in footer) +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Tab, k.Start, k.Stop, k.Refresh, k.Help, k.Quit} +} + +// FullHelp returns the full help text (displayed when '?' is pressed) +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Tab, k.ShiftTab, k.Up, k.Down}, + {k.PageUp, k.PageDown, k.Home, k.End}, + {k.Start, k.Stop, k.Refresh}, + {k.Enter, k.Space, k.Save}, + {k.Help, k.Quit}, + } +} + +func defaultKeyMap() keyMap { + return keyMap{ + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "next panel"), + ), + ShiftTab: key.NewBinding( + key.WithKeys("shift+tab"), + key.WithHelp("shift+tab", "prev panel"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up/scroll"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down/scroll"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "ctrl+u"), + key.WithHelp("pgup", "page up"), + ), + PageDown: key.NewBinding( + key.WithKeys("pgdown", "ctrl+d"), + key.WithHelp("pgdn", "page down"), + ), + Home: key.NewBinding( + key.WithKeys("home", "g"), + key.WithHelp("home/g", "top"), + ), + End: key.NewBinding( + key.WithKeys("end", "G"), + key.WithHelp("end/G", "bottom"), + ), + Start: key.NewBinding( + key.WithKeys("s"), + key.WithHelp("s", "start server"), + ), + Stop: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "stop server"), + ), + Refresh: key.NewBinding( + key.WithKeys("r"), + key.WithHelp("r", "refresh"), + ), + Help: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "toggle help"), + ), + Enter: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "edit/confirm"), + ), + Space: key.NewBinding( + key.WithKeys(" "), + key.WithHelp("space", "toggle"), + ), + Save: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "save config"), + ), + Quit: key.NewBinding( + key.WithKeys("q", "esc", "ctrl+c"), + key.WithHelp("q/esc", "exit"), + ), + } +} + +// Model Initialization + +// NewModel creates a new dashboard model with initial values +func NewModel() Model { + vp := viewport.New(80, 20) + vp.SetContent("Initializing log viewer...") + + h := help.New() + h.ShowAll = false + + // Initialize config sections as collapsed + sectionOpen := make(map[ConfigSection]bool) + for i := range ConfigSectionCount { + sectionOpen[i] = false + } + // Open Basic section by default + sectionOpen[ConfigSectionBasic] = true + + return Model{ + activePanel: PanelStatus, // Default + logViewport: vp, + help: h, + keys: defaultKeyMap(), + startTime: time.Now(), + lastRefresh: time.Now(), + connectedPlayers: make(map[string]string), + ssuiVersion: "...", + goRuntime: "...", + configSectionOpen: sectionOpen, + configItems: buildConfigItems(), // Populate config vals + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + tickCmd(), + tea.SetWindowTitle("SSUI Dashboard"), + ) +} + +// Messages + +// tickMsg is sent periodically to update the dashboard +type tickMsg time.Time + +// tickCmd returns a command that sends a tick message every second +func tickCmd() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// statusUpdateMsg is sent when server status is updated +type statusUpdateMsg struct { + // Server state + running bool + uptime time.Duration + startTime time.Time + serverName string + saveName string + worldID string + gamePort string + updatePort string + maxPlayers int + players map[string]string + + // Game info + gameVersion string + gameBranch string + buildID string + + // SSUI info + version string + goRuntime string + isDocker bool + + // Features + discordEnabled bool + autoSaveEnabled bool + autoRestartEnabled bool + autoRestartTimer string + upnpEnabled bool + authEnabled bool + bepInExEnabled bool + saveInterval string + autoStartEnabled bool + + // Backup + backupKeepLastN int + backupDailyFor int + backupWeeklyFor int + backupMonthlyFor int +} + +// logUpdateMsg is sent when new SSUI logs are available +type logUpdateMsg struct { + logs []string +} + +// serverActionMsg is sent after a server action (start/stop) +type serverActionMsg struct { + action string + err error +} + +// Helpers + +// formatUptime formats duration to a human-readable string +func formatUptime(d time.Duration) string { + if d == 0 { + return "N/A" + } + d = d.Round(time.Second) + + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + mins := int(d.Minutes()) % 60 + secs := int(d.Seconds()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm %ds", hours, mins, secs) + } + if mins > 0 { + return fmt.Sprintf("%dm %ds", mins, secs) + } + return fmt.Sprintf("%ds", secs) +} + +// formatUptimeShort returns a shorter uptime format +func formatUptimeShort(d time.Duration) string { + if d == 0 { + return "--:--:--" + } + d = d.Round(time.Second) + h := int(d.Hours()) + m := int(d.Minutes()) % 60 + s := int(d.Seconds()) % 60 + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} + +// parseMaxPlayers converts the string max players to int +func parseMaxPlayers(s string) int { + n, err := strconv.Atoi(s) + if err != nil { + return 0 + } + return n +} diff --git a/src/cli/dashboard/styles.go b/src/cli/dashboard/styles.go new file mode 100644 index 00000000..04a7c319 --- /dev/null +++ b/src/cli/dashboard/styles.go @@ -0,0 +1,638 @@ +package dashboard + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// SSUI Dashboard Style System +// A comprehensive styling module using lipgloss for a polished terminal UI + +// Color Palette - SSUI Brand Colors + +var ( + // Primary brand colors + Purple = lipgloss.Color("#7C3AED") + PurpleLight = lipgloss.Color("#A78BFA") + PurpleDark = lipgloss.Color("#5B21B6") + + // Semantic colors + Green = lipgloss.Color("#10B981") + GreenLight = lipgloss.Color("#34D399") + Red = lipgloss.Color("#EF4444") + RedLight = lipgloss.Color("#F87171") + Yellow = lipgloss.Color("#F59E0B") + YellowLight = lipgloss.Color("#FBBF24") + Blue = lipgloss.Color("#3B82F6") + BlueLight = lipgloss.Color("#60A5FA") + Cyan = lipgloss.Color("#06B6D4") + CyanLight = lipgloss.Color("#22D3EE") + + // Neutral colors + White = lipgloss.Color("#F9FAFB") + Gray100 = lipgloss.Color("#F3F4F6") + Gray200 = lipgloss.Color("#E5E7EB") + Gray300 = lipgloss.Color("#D1D5DB") + Gray400 = lipgloss.Color("#9CA3AF") + Gray500 = lipgloss.Color("#6B7280") + Gray600 = lipgloss.Color("#4B5563") + Gray700 = lipgloss.Color("#374151") + Gray800 = lipgloss.Color("#1F2937") + Gray900 = lipgloss.Color("#111827") + Black = lipgloss.Color("#030712") +) + +const ( + // Status indicators + BulletFilled = "●" + BulletEmpty = "○" + BulletHalf = "◐" + CheckMark = "✓" + CrossMark = "✗" + Lightning = "⚡" + Gear = "⚙" + Server = "󰒋" + Globe = "🌐" + Clock = "⏱" + Calendar = "📅" + Folder = "📁" + User = "👤" + Users = "👥" + Shield = "🛡" + Plug = "🔌" + Bot = "🤖" + Save = "💾" + Refresh = "🔄" + Play = "▶" + Stop = "■" + Pause = "⏸" + Rocket = "🚀" + Fire = "🔥" + Sparkle = "✨" + + // Box drawing characters + BoxTopLeft = "╭" + BoxTopRight = "╮" + BoxBottomLeft = "╰" + BoxBottomRight = "╯" + BoxHorizontal = "─" + BoxVertical = "│" + BoxCross = "┼" + BoxTeeRight = "├" + BoxTeeLeft = "┤" + BoxTeeDown = "┬" + BoxTeeUp = "┴" + + // Double line box + BoxDoubleH = "═" + BoxDoubleV = "║" + + // Progress bar characters + ProgressFull = "█" + ProgressHigh = "▓" + ProgressMid = "▒" + ProgressLow = "░" + ProgressEmpty = "░" + + // Sparkline characters (for mini graphs) + Spark1 = "▁" + Spark2 = "▂" + Spark3 = "▃" + Spark4 = "▄" + Spark5 = "▅" + Spark6 = "▆" + Spark7 = "▇" + Spark8 = "█" + + // Arrows and pointers + ArrowRight = "→" + ArrowLeft = "←" + ArrowUp = "↑" + ArrowDown = "↓" + Pointer = "▸" + Diamond = "◆" + Triangle = "▲" +) + +// Spinner frames for animated loading indicators +var SpinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} +var SpinnerDots = []string{"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"} +var SpinnerPulse = []string{"●", "◐", "○", "◑"} + +// Base Styles + +var ( + // Base text styles + BaseStyle = lipgloss.NewStyle(). + Foreground(White) + + BoldStyle = lipgloss.NewStyle(). + Foreground(White). + Bold(true) + + MutedStyle = lipgloss.NewStyle(). + Foreground(Gray500) + + DimStyle = lipgloss.NewStyle(). + Foreground(Gray600) +) + +// Logo and Header Styles + +var ( + // ASCII art logo style with gradient effect + LogoStyle = lipgloss.NewStyle(). + Foreground(Purple). + Bold(true) + + LogoAccentStyle = lipgloss.NewStyle(). + Foreground(PurpleLight) + + // Main header container + HeaderContainerStyle = lipgloss.NewStyle(). + Background(Gray800). + Padding(0, 2) + + // Title bar style + TitleStyle = lipgloss.NewStyle(). + Foreground(White). + Background(Purple). + Bold(true). + Padding(0, 2). + MarginRight(1) + + // Version badge + VersionBadgeStyle = lipgloss.NewStyle(). + Foreground(PurpleLight). + Background(Gray700). + Padding(0, 1) + + // Status pill styles (for header indicators) + StatusPillOnline = lipgloss.NewStyle(). + Foreground(Gray900). + Background(Green). + Bold(true). + Padding(0, 1) + + StatusPillOffline = lipgloss.NewStyle(). + Foreground(White). + Background(Red). + Bold(true). + Padding(0, 1) + + StatusPillWarning = lipgloss.NewStyle(). + Foreground(Gray900). + Background(Yellow). + Bold(true). + Padding(0, 1) +) + +// Tab Bar Styles + +var ( + TabBarStyle = lipgloss.NewStyle(). + Background(Gray800). + Padding(0, 1) + + TabActiveStyle = lipgloss.NewStyle(). + Foreground(White). + Background(Purple). + Bold(true). + Padding(0, 2) + + TabInactiveStyle = lipgloss.NewStyle(). + Foreground(Gray400). + Background(Gray700). + Padding(0, 2) + + TabSeparatorStyle = lipgloss.NewStyle(). + Foreground(Gray600) +) + +// Panel Styles + +var ( + // Main panel container with rounded border + PanelStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Purple). + Padding(1, 2) + + // Panel variants + PanelActiveStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(PurpleLight). + Padding(1, 2) + + PanelDimStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Gray600). + Padding(1, 2) + + // Section header within panels + SectionHeaderStyle = lipgloss.NewStyle(). + Foreground(PurpleLight). + Bold(true). + MarginBottom(1) + + // Divider line + DividerStyle = lipgloss.NewStyle(). + Foreground(Gray600) +) + +// Status Indicator Styles + +var ( + // Online/Offline indicators + OnlineStyle = lipgloss.NewStyle(). + Foreground(Green). + Bold(true) + + OfflineStyle = lipgloss.NewStyle(). + Foreground(Red). + Bold(true) + + WarningStyle = lipgloss.NewStyle(). + Foreground(Yellow). + Bold(true) + + InfoStyle = lipgloss.NewStyle(). + Foreground(Blue) + + // Feature status badges + FeatureEnabledStyle = lipgloss.NewStyle(). + Foreground(Gray900). + Background(Green). + Padding(0, 1) + + FeatureDisabledStyle = lipgloss.NewStyle(). + Foreground(Gray400). + Background(Gray700). + Padding(0, 1) + + FeatureActiveStyle = lipgloss.NewStyle(). + Foreground(Gray900). + Background(Cyan). + Padding(0, 1) +) + +// Data Display Styles + +var ( + // Key-value pair styles + LabelStyle = lipgloss.NewStyle(). + Foreground(Gray400). + Width(20) + + ValueStyle = lipgloss.NewStyle(). + Foreground(White) + + ValueHighlightStyle = lipgloss.NewStyle(). + Foreground(PurpleLight). + Bold(true) + + ValueSuccessStyle = lipgloss.NewStyle(). + Foreground(Green) + + ValueErrorStyle = lipgloss.NewStyle(). + Foreground(Red) + + ValueWarningStyle = lipgloss.NewStyle(). + Foreground(Yellow) + + // Numeric value style + NumberStyle = lipgloss.NewStyle(). + Foreground(Cyan) + + // ID/Code style (monospace look) + CodeStyle = lipgloss.NewStyle(). + Foreground(Gray300). + Italic(true) +) + +// Player List Styles + +var ( + PlayerRowStyle = lipgloss.NewStyle(). + Padding(0, 1) + + PlayerIndexStyle = lipgloss.NewStyle(). + Foreground(Gray500). + Width(4) + + PlayerNameStyle = lipgloss.NewStyle(). + Foreground(GreenLight). + Bold(true) + + PlayerIDStyle = lipgloss.NewStyle(). + Foreground(Gray500). + Italic(true) + + PlayerEmptyStyle = lipgloss.NewStyle(). + Foreground(Gray500). + Italic(true) +) + +// Log Viewer Styles + +var ( + LogContainerStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(Gray600). + Padding(0, 1) + + LogTimestampStyle = lipgloss.NewStyle(). + Foreground(Gray500) + + LogLevelDebugStyle = lipgloss.NewStyle(). + Foreground(Gray400) + + LogLevelInfoStyle = lipgloss.NewStyle(). + Foreground(Blue) + + LogLevelWarnStyle = lipgloss.NewStyle(). + Foreground(Yellow) + + LogLevelErrorStyle = lipgloss.NewStyle(). + Foreground(Red). + Bold(true) + + LogMessageStyle = lipgloss.NewStyle(). + Foreground(Gray300) + + LogScrollIndicatorStyle = lipgloss.NewStyle(). + Foreground(Gray500). + Italic(true) +) + +// Footer Styles + +var ( + FooterStyle = lipgloss.NewStyle(). + Background(Gray800). + Foreground(Gray400). + Padding(0, 2) + + FooterBrandStyle = lipgloss.NewStyle(). + Foreground(Purple). + Bold(true) + + FooterStatStyle = lipgloss.NewStyle(). + Foreground(Gray400) + + FooterSeparatorStyle = lipgloss.NewStyle(). + Foreground(Gray600) + + // Key hint styles + KeyStyle = lipgloss.NewStyle(). + Foreground(Gray300). + Background(Gray700). + Padding(0, 1) + + KeyDescStyle = lipgloss.NewStyle(). + Foreground(Gray500) +) + +// Help Styles + +var ( + HelpStyle = lipgloss.NewStyle(). + Foreground(Gray500) + + HelpKeyStyle = lipgloss.NewStyle(). + Foreground(Gray300) + + HelpDescStyle = lipgloss.NewStyle(). + Foreground(Gray500) + + HelpSeparatorStyle = lipgloss.NewStyle(). + Foreground(Gray600) +) + +// Utility Functions + +// RenderProgressBar creates a visual progress bar +func RenderProgressBar(current, max, width int) string { + if max == 0 { + max = 1 + } + if width < 5 { + width = 10 + } + + ratio := float64(current) / float64(max) + filled := min(int(ratio*float64(width)), width) + + bar := "" + for i := 0; i < width; i++ { + if i < filled { + bar += ProgressFull + } else { + bar += ProgressEmpty + } + } + + // Color based on fill level + var style lipgloss.Style + switch { + case ratio >= 0.9: + style = lipgloss.NewStyle().Foreground(Red) + case ratio >= 0.7: + style = lipgloss.NewStyle().Foreground(Yellow) + default: + style = lipgloss.NewStyle().Foreground(Green) + } + + return style.Render(bar) +} + +// RenderStatusDot returns a colored status dot +func RenderStatusDot(online bool) string { + if online { + return OnlineStyle.Render(BulletFilled) + } + return OfflineStyle.Render(BulletEmpty) +} + +// RenderFeatureBadge creates a badge for feature status +func RenderFeatureBadge(name string, enabled bool) string { + if enabled { + return FeatureEnabledStyle.Render(CheckMark + " " + name) + } + return FeatureDisabledStyle.Render(CrossMark + " " + name) +} + +// RenderKeyValue renders a styled key-value pair +func RenderKeyValue(key, value string) string { + return LabelStyle.Render(key+":") + " " + ValueStyle.Render(value) +} + +// RenderKeyValueHighlight renders a key-value pair with highlighted value +func RenderKeyValueHighlight(key, value string) string { + return LabelStyle.Render(key+":") + " " + ValueHighlightStyle.Render(value) +} + +// RenderDivider creates a horizontal divider line +func RenderDivider(width int) string { + if width < 1 { + width = 40 + } + var line strings.Builder + for i := 0; i < width; i++ { + line.WriteString(BoxHorizontal) + } + return DividerStyle.Render(line.String()) +} + +// RenderSectionTitle creates a styled section title +func RenderSectionTitle(title string) string { + return SectionHeaderStyle.Render(Diamond + " " + title) +} + +// Gradient applies a simple two-color gradient to text (line by line) +func Gradient(text string, from, to lipgloss.Color) string { + // Simple implementation: alternate colors by line + lines := splitLines(text) + result := "" + for i, line := range lines { + var style lipgloss.Style + if i%2 == 0 { + style = lipgloss.NewStyle().Foreground(from) + } else { + style = lipgloss.NewStyle().Foreground(to) + } + result += style.Render(line) + if i < len(lines)-1 { + result += "\n" + } + } + return result +} + +// splitLines splits text into lines +func splitLines(text string) []string { + var lines []string + current := "" + for _, r := range text { + if r == '\n' { + lines = append(lines, current) + current = "" + } else { + current += string(r) + } + } + if current != "" { + lines = append(lines, current) + } + return lines +} + +// GetSpinnerFrame returns the current spinner frame based on tick count +func GetSpinnerFrame(tickCount int) string { + return SpinnerFrames[tickCount%len(SpinnerFrames)] +} + +// GetSpinnerDot returns the current dot spinner frame based on tick count +func GetSpinnerDot(tickCount int) string { + return SpinnerDots[tickCount%len(SpinnerDots)] +} + +// GetPulse returns the current pulse frame based on tick count +func GetPulse(tickCount int) string { + return SpinnerPulse[tickCount%len(SpinnerPulse)] +} + +// RenderSparkline creates a mini sparkline from values +func RenderSparkline(values []int, maxVal int) string { + sparkChars := []string{Spark1, Spark2, Spark3, Spark4, Spark5, Spark6, Spark7, Spark8} + if maxVal <= 0 { + maxVal = 1 + for _, v := range values { + if v > maxVal { + maxVal = v + } + } + } + + result := "" + for _, v := range values { + idx := max(int(float64(v)/float64(maxVal)*float64(len(sparkChars)-1)), 0) + if idx >= len(sparkChars) { + idx = len(sparkChars) - 1 + } + result += sparkChars[idx] + } + return result +} + +// RenderMiniBar creates a compact horizontal bar +func RenderMiniBar(value, max int, width int) string { + if max <= 0 { + max = 1 + } + if width <= 0 { + width = 10 + } + + filled := int(float64(value) / float64(max) * float64(width)) + if filled > width { + filled = width + } + + result := "" + for i := 0; i < width; i++ { + if i < filled { + result += "▰" + } else { + result += "▱" + } + } + return result +} + +// RenderAnimatedDots creates animated dots based on tick count +func RenderAnimatedDots(tickCount int) string { + dots := []string{"", ".", "..", "..."} + return dots[tickCount%len(dots)] +} + +// RenderBoxedText creates text surrounded by a box +func RenderBoxedText(text string, color lipgloss.Color) string { + width := len(text) + 2 + top := BoxTopLeft + repeat(BoxHorizontal, width) + BoxTopRight + bottom := BoxBottomLeft + repeat(BoxHorizontal, width) + BoxBottomRight + middle := BoxVertical + " " + text + " " + BoxVertical + + style := lipgloss.NewStyle().Foreground(color) + return style.Render(top + "\n" + middle + "\n" + bottom) +} + +// repeat repeats a string n times +func repeat(s string, n int) string { + result := "" + for range n { + result += s + } + return result +} + +// RenderStatusIndicator creates an animated status indicator +func RenderStatusIndicator(online bool, tickCount int) string { + if online { + // Pulsing green dot when online + pulse := GetPulse(tickCount) + return lipgloss.NewStyle().Foreground(Green).Bold(true).Render(pulse) + } + // Static empty circle when offline + return lipgloss.NewStyle().Foreground(Red).Render(BulletEmpty) +} + +// RenderActivityIndicator shows activity with animated spinner +func RenderActivityIndicator(active bool, tickCount int, label string) string { + if active { + spinner := GetSpinnerFrame(tickCount) + return lipgloss.NewStyle().Foreground(Cyan).Render(spinner) + " " + label + } + return MutedStyle.Render("○ " + label) +} diff --git a/src/cli/dashboard/update.go b/src/cli/dashboard/update.go new file mode 100644 index 00000000..8708ce91 --- /dev/null +++ b/src/cli/dashboard/update.go @@ -0,0 +1,402 @@ +package dashboard + +import ( + "runtime" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/detectionmgr" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/managers/gamemgr" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +// Update implements tea.Model - handles all messages and key presses +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + // Handle key presses + + // When editing config, ONLY handle editing-related keys + if m.configEditing && m.activePanel == PanelConfig { + switch msg.Type { + case tea.KeyEnter: + // Confirm edit - save the new value + item, isHeader, _ := m.getConfigItemAtIndex(m.configSelectedIndex) + if item != nil && !isHeader { + for i := range m.configItems { + if m.configItems[i].Key == item.Key { + m.configItems[i].Value = m.configEditValue + break + } + } + m.configHasChanges = true + } + m.configEditing = false + m.configEditValue = "" + case tea.KeyEscape: + // Cancel editing + m.configEditing = false + m.configEditValue = "" + case tea.KeyBackspace: + if len(m.configEditValue) > 0 { + m.configEditValue = m.configEditValue[:len(m.configEditValue)-1] + } + case tea.KeySpace: + // Insert a space character when editing + m.configEditValue += " " + case tea.KeyRunes: + m.configEditValue += string(msg.Runes) + } + return m, nil // Don't process other keybinds while editing + } + + switch { + case key.Matches(msg, m.keys.Quit): + m.quitting = true + return m, tea.Quit + + case key.Matches(msg, m.keys.Tab): + // Cycle forward through panels + m.activePanel = (m.activePanel + 1) % PanelCount + + case key.Matches(msg, m.keys.ShiftTab): + // Cycle backward through panels + m.activePanel = (m.activePanel - 1 + PanelCount) % PanelCount + + case key.Matches(msg, m.keys.Help): + m.help.ShowAll = !m.help.ShowAll + m.showFullHelp = m.help.ShowAll + + case key.Matches(msg, m.keys.Up): + if m.activePanel == PanelSSUILog { + m.logViewport.LineUp(3) + } else if m.activePanel == PanelConfig && !m.configEditing { + if m.configSelectedIndex > 0 { + m.configSelectedIndex-- + } + } + + case key.Matches(msg, m.keys.Down): + if m.activePanel == PanelSSUILog { + m.logViewport.LineDown(3) + } else if m.activePanel == PanelConfig && !m.configEditing { + if m.configSelectedIndex < m.getTotalConfigItems()-1 { + m.configSelectedIndex++ + } + } + + case key.Matches(msg, m.keys.PageUp): + if m.activePanel == PanelSSUILog { + m.logViewport.HalfPageUp() + } + + case key.Matches(msg, m.keys.PageDown): + if m.activePanel == PanelSSUILog { + m.logViewport.HalfPageDown() + } + + case key.Matches(msg, m.keys.Home): + if m.activePanel == PanelSSUILog { + m.logViewport.GotoTop() + } + + case key.Matches(msg, m.keys.End): + if m.activePanel == PanelSSUILog { + m.logViewport.GotoBottom() + } + + case key.Matches(msg, m.keys.Start): + if !m.serverRunning { + return m, startServerCmd() + } + + case key.Matches(msg, m.keys.Stop): + if m.serverRunning { + return m, stopServerCmd() + } + + case key.Matches(msg, m.keys.Refresh): + // Force refresh of all data + return m, tea.Batch(fetchStatusCmd(), fetchLogsCmd()) + + case key.Matches(msg, m.keys.Enter): + if m.activePanel == PanelConfig { + if m.configEditing { + // Confirm edit - save the new value + item, isHeader, _ := m.getConfigItemAtIndex(m.configSelectedIndex) + if item != nil && !isHeader { + // Update the item value + for i := range m.configItems { + if m.configItems[i].Key == item.Key { + m.configItems[i].Value = m.configEditValue + break + } + } + m.configHasChanges = true + } + m.configEditing = false + m.configEditValue = "" + } else { + // Start editing + item, isHeader, section := m.getConfigItemAtIndex(m.configSelectedIndex) + if isHeader { + // Toggle section open/closed + m.configSectionOpen[section] = !m.configSectionOpen[section] + } else if item != nil { + if item.Type == "bool" { + // Toggle bool directly + if item.Value == "true" { + item.Value = "false" + } else { + item.Value = "true" + } + // Update in configItems + for i := range m.configItems { + if m.configItems[i].Key == item.Key { + m.configItems[i].Value = item.Value + break + } + } + m.configHasChanges = true + } else { + // Start text editing + m.configEditing = true + m.configEditValue = item.Value + } + } + } + } + + case key.Matches(msg, m.keys.Space): + if m.activePanel == PanelConfig && !m.configEditing { + item, isHeader, section := m.getConfigItemAtIndex(m.configSelectedIndex) + if isHeader { + // Toggle section open/closed + m.configSectionOpen[section] = !m.configSectionOpen[section] + } else if item != nil && item.Type == "bool" { + // Toggle bool value + newVal := "true" + if item.Value == "true" { + newVal = "false" + } + for i := range m.configItems { + if m.configItems[i].Key == item.Key { + m.configItems[i].Value = newVal + break + } + } + m.configHasChanges = true + } + } + + case key.Matches(msg, m.keys.Save): + if m.activePanel == PanelConfig && m.configHasChanges && !m.configEditing { + // Save all config changes + err := saveAllConfigChanges(m.configItems) + if err != nil { + m.configStatusMsg = "✗ " + err.Error() + } else { + m.configStatusMsg = "✓ Saved & reloaded" + m.configHasChanges = false + // Reload config items to reflect any changes + m.configItems = buildConfigItems() + } + m.configStatusTick = 30 // Show for ~3 seconds + } + } + + case tea.WindowSizeMsg: + // Window was resized + m.width = msg.Width + m.height = msg.Height + + // Calculate content area height + headerHeight := 6 // Status bar + tabs + footerHeight := 2 // Footer + panelPadding := 6 // Border + padding + + contentHeight := m.height - headerHeight - footerHeight - panelPadding + if contentHeight < 5 { + contentHeight = 5 + } + contentWidth := m.width - 8 // Account for panel borders and padding + if contentWidth < 20 { + contentWidth = 20 + } + + // Update viewport + m.logViewport.Width = contentWidth + m.logViewport.Height = contentHeight + m.help.Width = m.width + + case tickMsg: + // Periodic update - refresh data every second + m.tickCount++ + cmds = append(cmds, tickCmd()) // Schedule next tick + cmds = append(cmds, fetchStatusCmd()) + cmds = append(cmds, fetchLogsCmd()) + + // Decrement config status message timer + if m.configStatusTick > 0 { + m.configStatusTick-- + if m.configStatusTick == 0 { + m.configStatusMsg = "" + } + } + + case statusUpdateMsg: + // Update all status fields from fetched data + m.serverRunning = msg.running + m.serverUptime = msg.uptime + m.serverStartTime = msg.startTime + m.serverName = msg.serverName + m.saveName = msg.saveName + m.worldID = msg.worldID + m.gamePort = msg.gamePort + m.updatePort = msg.updatePort + m.maxPlayers = msg.maxPlayers + m.connectedPlayers = msg.players + + // Game info + m.gameVersion = msg.gameVersion + m.gameBranch = msg.gameBranch + m.buildID = msg.buildID + + // SSUI info + m.ssuiVersion = msg.version + m.goRuntime = msg.goRuntime + m.isDocker = msg.isDocker + + // Features + m.discordEnabled = msg.discordEnabled + m.autoSaveEnabled = msg.autoSaveEnabled + m.autoRestartEnabled = msg.autoRestartEnabled + m.autoRestartTimer = msg.autoRestartTimer + m.upnpEnabled = msg.upnpEnabled + m.authEnabled = msg.authEnabled + m.bepInExEnabled = msg.bepInExEnabled + m.saveInterval = msg.saveInterval + m.autoStartEnabled = msg.autoStartEnabled + + // Backup info + m.backupKeepLastN = msg.backupKeepLastN + m.backupDailyFor = msg.backupDailyFor + m.backupWeeklyFor = msg.backupWeeklyFor + m.backupMonthlyFor = msg.backupMonthlyFor + + m.lastRefresh = msg.startTime + + case logUpdateMsg: + // Update log viewport content + if len(msg.logs) > 0 { + content := strings.Join(msg.logs, "") + m.logViewport.SetContent(content) + m.logViewport.GotoBottom() + } + + case serverActionMsg: + // Handle server action results + if msg.err != nil { + m.err = msg.err + } + // Refresh status after action + cmds = append(cmds, fetchStatusCmd()) + } + + return m, tea.Batch(cmds...) +} + +// fetchStatusCmd returns a command that fetches current server status from real sources +func fetchStatusCmd() tea.Cmd { + return func() tea.Msg { + // Get real data from config and managers + running := config.GetIsGameServerRunning() + uptime := gamemgr.GetServerUptime() + startTime := gamemgr.GetServerStartTime() + + // Get connected players + detector := detectionmgr.GetDetector() + players := detectionmgr.GetPlayers(detector) + + // Parse auto restart timer - check if it's enabled (not empty or "0") + autoRestartTimer := config.GetAutoRestartServerTimer() + autoRestartEnabled := autoRestartTimer != "" && autoRestartTimer != "0" + + return statusUpdateMsg{ + // Server state + running: running, + uptime: uptime, + startTime: startTime, + serverName: config.GetServerName(), + saveName: config.GetSaveName(), + worldID: config.GetWorldID(), + gamePort: config.GetGamePort(), + updatePort: config.GetUpdatePort(), + maxPlayers: parseMaxPlayers(config.GetServerMaxPlayers()), + players: players, + + // Game info + gameVersion: config.GetExtractedGameVersion(), + gameBranch: config.GetGameBranch(), + buildID: config.GetCurrentBranchBuildID(), + + // SSUI info + version: config.GetVersion(), + goRuntime: runtime.GOOS + "/" + runtime.GOARCH, + isDocker: config.GetIsDockerContainer(), + + // Features + discordEnabled: config.GetIsDiscordEnabled(), + autoSaveEnabled: config.GetAutoSave(), + autoRestartEnabled: autoRestartEnabled, + autoRestartTimer: autoRestartTimer, + upnpEnabled: config.GetUPNPEnabled(), + authEnabled: config.GetAuthEnabled(), + bepInExEnabled: config.GetIsSSCMEnabled(), // SSCM is the BepInEx integration + saveInterval: config.GetSaveInterval(), + autoStartEnabled: config.GetAutoStartServerOnStartup(), + + // Backup + backupKeepLastN: config.GetBackupKeepLastN(), + backupDailyFor: int(config.GetBackupKeepDailyFor().Hours() / 24), // Convert duration to days + backupWeeklyFor: int(config.GetBackupKeepWeeklyFor().Hours() / 168), // Convert to weeks + backupMonthlyFor: int(config.GetBackupKeepMonthlyFor().Hours() / 720), // Convert to months (approx) + } + } +} + +// fetchLogsCmd returns a command that fetches SSUI log entries +func fetchLogsCmd() tea.Cmd { + return func() tea.Msg { + logs := GetLogBuffer() + + if len(logs) == 0 { + logs = []string{ + "Waiting for log entries...\n", + "\n", + "Backend activity will appear here.\n", + } + } + + return logUpdateMsg{logs: logs} + } +} + +// startServerCmd starts the game server +func startServerCmd() tea.Cmd { + return func() tea.Msg { + err := gamemgr.InternalStartServer() + return serverActionMsg{action: "start", err: err} + } +} + +// stopServerCmd stops the game server +func stopServerCmd() tea.Cmd { + return func() tea.Msg { + err := gamemgr.InternalStopServer() + return serverActionMsg{action: "stop", err: err} + } +} diff --git a/src/cli/dashboard/view.go b/src/cli/dashboard/view.go new file mode 100644 index 00000000..ba5c0369 --- /dev/null +++ b/src/cli/dashboard/view.go @@ -0,0 +1,514 @@ +package dashboard + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +// SSUI ASCII Art Logo + +const ssuiLogo = `███████╗███████╗██╗ ██╗██╗ +██╔════╝██╔════╝██║ ██║██║ +███████╗███████╗██║ ██║██║ +╚════██║╚════██║██║ ██║██║ +███████║███████║╚██████╔╝██║ +╚══════╝╚══════╝ ╚═════╝ ╚═╝` + +// Main View + +// View implements tea.Model - renders the entire UI +func (m Model) View() string { + if m.quitting { + farewell := lipgloss.NewStyle(). + Foreground(Purple). + Bold(true). + Render("\n 👋 Dashboard closed. Returning to CLI...\n\n") + return farewell + } + + // Build the UI + var b strings.Builder + + // Header with logo and status bar + b.WriteString(m.renderHeader()) + b.WriteString("\n") + + // Tab bar + b.WriteString(m.renderTabBar()) + b.WriteString("\n\n") + + // Main content panel + switch m.activePanel { + case PanelStatus: + b.WriteString(m.renderStatusPanel()) + case PanelSSUILog: + b.WriteString(m.renderLogPanel()) + case PanelPlayers: + b.WriteString(m.renderPlayersPanel()) + case PanelConfig: + b.WriteString(m.renderConfigPanel()) + } + + b.WriteString("\n") + + // Footer with all controls + b.WriteString(m.renderFooter()) + + return b.String() +} + +// Header Components + +// renderHeader renders the header with status indicators +func (m Model) renderHeader() string { + // Status indicators + var statusItems []string + + // Server status pill + if m.serverRunning { + statusItems = append(statusItems, StatusPillOnline.Render(" "+BulletFilled+" ONLINE ")) + } else { + statusItems = append(statusItems, StatusPillOffline.Render(" "+BulletEmpty+" OFFLINE ")) + } + + // Player count with mini progress bar + playerCount := len(m.connectedPlayers) + playerBar := RenderMiniBar(playerCount, m.maxPlayers, 6) + playerText := fmt.Sprintf("%d/%d", playerCount, m.maxPlayers) + if playerCount > 0 { + playerStyle := lipgloss.NewStyle().Foreground(Cyan).Bold(true) + statusItems = append(statusItems, playerStyle.Render("👥 "+playerBar+" "+playerText)) + } else { + statusItems = append(statusItems, MutedStyle.Render("👥 "+playerBar+" "+playerText)) + } + + // Uptime (if running) + if m.serverRunning && m.serverUptime > 0 { + uptimeStyle := lipgloss.NewStyle().Foreground(Green) + statusItems = append(statusItems, uptimeStyle.Render("⏱ "+formatUptimeShort(m.serverUptime))) + } + + // Version badge + statusItems = append(statusItems, VersionBadgeStyle.Render("v"+m.ssuiVersion)) + + statusBar := lipgloss.JoinHorizontal(lipgloss.Center, strings.Join(statusItems, " ")) + + // Simple centered status bar + return lipgloss.NewStyle(). + Width(m.width). + Align(lipgloss.Center). + Render(statusBar) +} + +// renderTabBar renders the navigation tab bar +func (m Model) renderTabBar() string { + var tabs []string + + for i := Panel(0); i < PanelCount; i++ { + icon := panelIcons[i] + name := panelNames[i] + label := icon + " " + name + + if i == m.activePanel { + tabs = append(tabs, TabActiveStyle.Render(label)) + } else { + tabs = append(tabs, TabInactiveStyle.Render(label)) + } + } + + tabBar := lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + + // Add subtle divider line under tabs + dividerWidth := lipgloss.Width(tabBar) + if m.width > dividerWidth { + dividerWidth = m.width - 4 + } + divider := DividerStyle.Render(strings.Repeat("─", dividerWidth)) + + return lipgloss.JoinVertical(lipgloss.Left, tabBar, divider) +} + +// Status Panel + +func (m Model) renderStatusPanel() string { + // Left column: Server info + leftCol := m.renderServerInfoBox() + + // Right column: World info + Quick stats + rightCol := lipgloss.JoinVertical(lipgloss.Left, + m.renderWorldInfoBox(), + "", + m.renderQuickStatsBox(), + ) + + // Combine columns + leftWidth := m.width/2 - 4 + rightWidth := m.width/2 - 4 + if leftWidth < 30 { + leftWidth = 30 + } + if rightWidth < 30 { + rightWidth = 30 + } + + leftColStyled := lipgloss.NewStyle().Width(leftWidth).Render(leftCol) + rightColStyled := lipgloss.NewStyle().Width(rightWidth).Render(rightCol) + + content := lipgloss.JoinHorizontal(lipgloss.Top, leftColStyled, " ", rightColStyled) + + return m.renderPanelContainer(content) +} + +// renderLogoBlock renders the SSUI ASCII logo with gradient +func (m Model) renderLogoBlock() string { + logoLines := strings.Split(ssuiLogo, "\n") + var styledLogo strings.Builder + + // Apply gradient colors to logo lines + gradientColors := []lipgloss.Color{PurpleLight, Purple, Purple, PurpleDark, PurpleDark, Gray500} + for i, line := range logoLines { + color := gradientColors[i%len(gradientColors)] + styledLogo.WriteString(lipgloss.NewStyle().Foreground(color).Bold(true).Render(line)) + if i < len(logoLines)-1 { + styledLogo.WriteString("\n") + } + } + return styledLogo.String() +} + +func (m Model) renderServerInfoBox() string { + var lines []string + + lines = append(lines, RenderSectionTitle("Server Status")) + lines = append(lines, "") + + // Status line (simple, no animation) + var statusLine string + if m.serverRunning { + statusLine = lipgloss.NewStyle().Foreground(Green).Bold(true).Render(BulletFilled+" Online") + + MutedStyle.Render(" • ") + + lipgloss.NewStyle().Foreground(Cyan).Render("⏱ "+formatUptime(m.serverUptime)) + } else { + statusLine = OfflineStyle.Render(BulletEmpty+" Offline") + + MutedStyle.Render(" • press ") + + KeyStyle.Render("s") + + MutedStyle.Render(" to start") + } + lines = append(lines, statusLine) + lines = append(lines, "") + + // Server details with icons + lines = append(lines, " "+lipgloss.NewStyle().Foreground(PurpleLight).Render("🏷")+" "+RenderKeyValue("Server Name", m.serverName)) + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Cyan).Render("🌐")+" "+RenderKeyValue("Game Port", m.gamePort)) + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Blue).Render("📡")+" "+RenderKeyValue("Update Port", m.updatePort)) + lines = append(lines, "") + + // Player capacity with fancy progress bar + playerCount := len(m.connectedPlayers) + barWidth := 20 + playerBar := RenderProgressBar(playerCount, m.maxPlayers, barWidth) + playerText := fmt.Sprintf("%d/%d", playerCount, m.maxPlayers) + + // Add player icon and styling + playerIcon := "👥" + if playerCount == 0 { + playerIcon = MutedStyle.Render("👥") + } + + lines = append(lines, " "+playerIcon+" "+LabelStyle.Render("Players:")) + lines = append(lines, " "+playerBar+" "+NumberStyle.Render(playerText)) + lines = append(lines, "") + + // Add SSUI Logo underneath player count + lines = append(lines, m.renderLogoBlock()) + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m Model) renderWorldInfoBox() string { + var lines []string + + lines = append(lines, RenderSectionTitle("World Info")) + lines = append(lines, "") + + // World details with icons + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Green).Render("💾")+" "+RenderKeyValue("Save Name", m.saveName)) + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Cyan).Render("🔑")+" "+RenderKeyValue("World ID", m.worldID)) + lines = append(lines, "") + + // Game version with special formatting + versionDisplay := m.gameVersion + if versionDisplay == "" { + versionDisplay = MutedStyle.Render("Unknown") + } else { + versionDisplay = ValueHighlightStyle.Render(versionDisplay) + } + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Yellow).Render("📦")+" "+LabelStyle.Render("Game Version:")+" "+versionDisplay) + + // Branch + branchDisplay := m.gameBranch + if branchDisplay == "" || branchDisplay == "public" { + branchDisplay = lipgloss.NewStyle().Foreground(Green).Render("public") + } else { + branchDisplay = lipgloss.NewStyle().Foreground(Yellow).Render(branchDisplay) + } + lines = append(lines, " "+lipgloss.NewStyle().Foreground(PurpleLight).Render("🌿")+" "+LabelStyle.Render("Branch:")+" "+branchDisplay) + + // Build ID if available + if m.buildID != "" { + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Gray500).Render("🔢")+" "+RenderKeyValue("Build ID", m.buildID)) + } + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +func (m Model) renderQuickStatsBox() string { + var lines []string + + lines = append(lines, RenderSectionTitle("SSUI Info")) + lines = append(lines, "") + + // Version with branding + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Purple).Bold(true).Render(Rocket)+" "+LabelStyle.Render("Version:")+" "+ValueHighlightStyle.Render(m.ssuiVersion)) + + // Runtime info + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Blue).Render("⚡")+" "+RenderKeyValue("Runtime", m.goRuntime)) + + // Environment with icon + if m.isDocker { + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Cyan).Render("🐳")+" "+LabelStyle.Render("Environment:")+" "+ValueHighlightStyle.Render("Docker")) + } else { + lines = append(lines, " "+lipgloss.NewStyle().Foreground(Green).Render("💻")+" "+LabelStyle.Render("Environment:")+" "+ValueStyle.Render("Native")) + } + + // Session info with animated indicator + sessionUptime := time.Since(m.startTime).Round(time.Second) + lines = append(lines, "") + sessionIcon := GetSpinnerDot(m.tickCount) + lines = append(lines, " "+lipgloss.NewStyle().Foreground(PurpleLight).Render(sessionIcon)+" "+LabelStyle.Render("Session:")+" "+ValueStyle.Render(formatUptime(sessionUptime))) + + return lipgloss.JoinVertical(lipgloss.Left, lines...) +} + +// Players Panel + +func (m Model) renderPlayersPanel() string { + var content string + + if len(m.connectedPlayers) == 0 { + // Empty state with nice styling + emptyIcon := lipgloss.NewStyle(). + Foreground(Gray500). + Render("👥") + + emptyTitle := lipgloss.NewStyle(). + Foreground(Gray400). + Bold(true). + Render("No Players Connected") + + var emptySubtext string + if !m.serverRunning { + emptySubtext = MutedStyle.Render("Server is offline. Press ") + + KeyStyle.Render("s") + + MutedStyle.Render(" to start.") + } else { + // Show animated waiting indicator + spinner := GetSpinnerFrame(m.tickCount) + emptySubtext = lipgloss.NewStyle().Foreground(Cyan).Render(spinner) + + MutedStyle.Render(" Waiting for players to join...") + } + + content = lipgloss.JoinVertical(lipgloss.Center, + "", + "", + emptyIcon, + "", + emptyTitle, + "", + emptySubtext, + "", + "", + ) + + // Center the content + content = lipgloss.NewStyle(). + Width(m.width - 8). + Align(lipgloss.Center). + Render(content) + } else { + // Player list header with player bar + playerBar := RenderProgressBar(len(m.connectedPlayers), m.maxPlayers, 15) + headerLine := lipgloss.JoinHorizontal(lipgloss.Top, + RenderSectionTitle("Connected Players"), + " ", + playerBar, + " ", + NumberStyle.Render(fmt.Sprintf("%d/%d", len(m.connectedPlayers), m.maxPlayers)), + ) + + // Column headers with better styling + colHeader := lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.NewStyle().Width(4).Foreground(Gray600).Render("#"), + lipgloss.NewStyle().Width(26).Foreground(Gray500).Bold(true).Render("Player Name"), + lipgloss.NewStyle().Foreground(Gray500).Bold(true).Render("Steam ID"), + ) + + // Fancy divider + divider := DividerStyle.Render(BoxTeeRight + strings.Repeat(BoxHorizontal, 60) + BoxTeeLeft) + + // Player rows + var rows []string + rows = append(rows, headerLine) + rows = append(rows, "") + rows = append(rows, colHeader) + rows = append(rows, divider) + + // Sort players for consistent display + var steamIDs []string + for steamID := range m.connectedPlayers { + steamIDs = append(steamIDs, steamID) + } + sort.Strings(steamIDs) + + for i, steamID := range steamIDs { + playerName := m.connectedPlayers[steamID] + + // Alternate row styling for readability + indexStyle := lipgloss.NewStyle().Width(4).Foreground(Gray500) + nameStyle := lipgloss.NewStyle().Width(26).Foreground(GreenLight).Bold(true) + idStyle := lipgloss.NewStyle().Foreground(Gray500).Italic(true) + + // Add online indicator + onlineIndicator := lipgloss.NewStyle().Foreground(Green).Render(BulletFilled) + + row := lipgloss.JoinHorizontal(lipgloss.Top, + indexStyle.Render(fmt.Sprintf("%d.", i+1)), + onlineIndicator+" "+nameStyle.Render(playerName), + idStyle.Render(" "+steamID), + ) + rows = append(rows, row) + } + + content = lipgloss.JoinVertical(lipgloss.Left, rows...) + } + + return m.renderPanelContainer(content) +} + +// Log Panel + +func (m Model) renderLogPanel() string { + // Header with scroll indicator + scrollPercent := int(m.logViewport.ScrollPercent() * 100) + scrollIndicator := LogScrollIndicatorStyle.Render(fmt.Sprintf("(%d%%)", scrollPercent)) + + // Scroll position indicator + var scrollHint string + if scrollPercent < 100 { + scrollHint = MutedStyle.Render(" • Use ↑↓ to scroll, 'G' for bottom") + } + + header := lipgloss.JoinHorizontal(lipgloss.Top, + RenderSectionTitle("SSUI Logs"), + " ", + scrollIndicator, + scrollHint, + ) + + // Log content + content := lipgloss.JoinVertical(lipgloss.Left, + header, + "", + m.logViewport.View(), + ) + + // Use a slightly different style for log panel + panelWidth := m.width - 4 + if panelWidth < 40 { + panelWidth = 40 + } + + return LogContainerStyle. + Width(panelWidth). + BorderForeground(Purple). + Render(content) +} + +// Footer + +func (m Model) renderFooter() string { + // Left: Branding + brand := FooterBrandStyle.Render("SSUI") + version := MutedStyle.Render(" v" + m.ssuiVersion) + left := brand + version + + // Center: All key hints in one comprehensive line + var keyHints []string + + keyHints = append(keyHints, + KeyStyle.Render("tab")+KeyDescStyle.Render(" panels"), + KeyStyle.Render("↑↓")+KeyDescStyle.Render(" nav"), + KeyStyle.Render("s")+KeyDescStyle.Render(" start"), + KeyStyle.Render("x")+KeyDescStyle.Render(" stop"), + ) + + // Add config-specific hints when on config panel + if m.activePanel == PanelConfig { + keyHints = append(keyHints, + KeyStyle.Render("enter")+KeyDescStyle.Render(" edit"), + KeyStyle.Render("ctrl+s")+KeyDescStyle.Render(" save"), + ) + } + + keyHints = append(keyHints, + KeyStyle.Render("r")+KeyDescStyle.Render(" refresh"), + KeyStyle.Render("q")+KeyDescStyle.Render(" quit"), + ) + + center := lipgloss.JoinHorizontal(lipgloss.Top, + strings.Join(keyHints, FooterSeparatorStyle.Render(" │ ")), + ) + + // Calculate spacing - simple centered layout + leftWidth := lipgloss.Width(left) + centerWidth := lipgloss.Width(center) + totalContentWidth := leftWidth + centerWidth + + var footerContent string + if m.width > totalContentWidth+10 { + // Full layout with spacing + spacing := max((m.width-totalContentWidth)/2, 2) + footerContent = left + strings.Repeat(" ", spacing) + center + } else { + // Compact layout + footerContent = left + " " + center + } + + return FooterStyle.Width(m.width).Render(footerContent) +} + +// Helper Functions + +// renderPanelContainer wraps content in the standard panel style +func (m Model) renderPanelContainer(content string) string { + // Calculate consistent content height + headerHeight := 6 // Status bar + tabs + footerHeight := 2 // Footer + panelPadding := 4 // Border + padding + + contentHeight := max(m.height-headerHeight-footerHeight-panelPadding, 10) + + panelWidth := m.width - 4 + if panelWidth < 40 { + panelWidth = 40 + } + + return PanelStyle. + Width(panelWidth). + Height(contentHeight). + Render(content) +} diff --git a/src/cli/ssuicli.go b/src/cli/ssuicli.go index f343f5fe..625444b9 100644 --- a/src/cli/ssuicli.go +++ b/src/cli/ssuicli.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/cli/dashboard" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" ) @@ -54,6 +55,18 @@ func StartConsole(wg *sync.WaitGroup) { wg.Add(1) go func() { defer wg.Done() + + // Auto-launch dashboard on interactive terminals if enabled in config + if config.GetIsCLIDashboardEnabled() && dashboard.IsInteractiveTerminal() { + time.Sleep(3 * time.Second) // Give other subsystems time to initialize + logger.Core.Info("CLI Dashboard is enabled, launching...") + time.Sleep(500 * time.Millisecond) // Small delay for log to be visible + if err := dashboard.Run(); err != nil { + logger.Core.Error("Dashboard exited with error: " + err.Error()) + } + logger.Core.Info("Dashboard closed, returning to SSUICLI prompt...") + } + scanner := bufio.NewScanner(os.Stdin) logger.Core.Info("SSUICLI runtime console started. Type 'help' for commands.") time.Sleep(10 * time.Millisecond) diff --git a/src/config/config.go b/src/config/config.go index ddb525cb..3fb6d76a 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -11,7 +11,7 @@ import ( var ( // All configuration variables can be found in vars.go - Version = "5.11.1" + Version = "5.12.3" Branch = "release" ) @@ -71,10 +71,12 @@ type JsonConfig struct { IsSSCMEnabled *bool `json:"IsSSCMEnabled"` AutoRestartServerTimer string `json:"AutoRestartServerTimer"` IsConsoleEnabled *bool `json:"IsConsoleEnabled"` + IsCLIDashboardEnabled *bool `json:"IsCLIDashboardEnabled"` LanguageSetting string `json:"LanguageSetting"` AutoStartServerOnStartup *bool `json:"AutoStartServerOnStartup"` SSUIIdentifier string `json:"SSUIIdentifier"` SSUIWebPort string `json:"SSUIWebPort"` + ShowExpertSettings *bool `json:"ShowExpertSettings"` // Show Expert Settings tab in web UI // Update Settings IsUpdateEnabled *bool `json:"IsUpdateEnabled"` @@ -87,17 +89,19 @@ type JsonConfig struct { IsStationeersLaunchPadAutoUpdatesEnabled *bool `json:"IsStationeersLaunchPadAutoUpdatesEnabled"` // Discord Settings - DiscordToken string `json:"discordToken"` - ControlChannelID string `json:"controlChannelID"` - StatusChannelID string `json:"statusChannelID"` - ConnectionListChannelID string `json:"connectionListChannelID"` - LogChannelID string `json:"logChannelID"` - SaveChannelID string `json:"saveChannelID"` - ControlPanelChannelID string `json:"controlPanelChannelID"` - DiscordCharBufferSize int `json:"DiscordCharBufferSize"` - BlackListFilePath string `json:"blackListFilePath"` - IsDiscordEnabled *bool `json:"isDiscordEnabled"` - ErrorChannelID string `json:"errorChannelID"` + DiscordToken string `json:"discordToken"` + ControlChannelID string `json:"controlChannelID"` + StatusChannelID string `json:"statusChannelID"` + ConnectionListChannelID string `json:"connectionListChannelID"` + LogChannelID string `json:"logChannelID"` + SaveChannelID string `json:"saveChannelID"` + ControlPanelChannelID string `json:"controlPanelChannelID"` + ServerInfoPanelChannelID string `json:"serverInfoPanelChannelID"` + DiscordCharBufferSize int `json:"DiscordCharBufferSize"` + BlackListFilePath string `json:"blackListFilePath"` + IsDiscordEnabled *bool `json:"isDiscordEnabled"` + RotateServerPassword *bool `json:"rotateServerPassword"` + ErrorChannelID string `json:"errorChannelID"` //Backup Settings BackupKeepLastN int `json:"backupKeepLastN"` // Number of most recent backups to keep (default: 2000) @@ -146,6 +150,7 @@ func applyConfig(cfg *JsonConfig) { LogChannelID = getString(cfg.LogChannelID, "LOG_CHANNEL_ID", "") SaveChannelID = getString(cfg.SaveChannelID, "SAVE_CHANNEL_ID", "") ControlPanelChannelID = getString(cfg.ControlPanelChannelID, "CONTROL_PANEL_CHANNEL_ID", "") + ServerInfoPanelChannelID = getString(cfg.ServerInfoPanelChannelID, "SERVER_INFO_PANEL_CHANNEL_ID", "") DiscordCharBufferSize = getInt(cfg.DiscordCharBufferSize, "DISCORD_CHAR_BUFFER_SIZE", 1000) BlackListFilePath = getString(cfg.BlackListFilePath, "BLACKLIST_FILE_PATH", "./Blacklist.txt") @@ -153,6 +158,10 @@ func applyConfig(cfg *JsonConfig) { IsDiscordEnabled = isDiscordEnabledVal cfg.IsDiscordEnabled = &isDiscordEnabledVal + rotateServerPasswordVal := getBool(cfg.RotateServerPassword, "ROTATE_SERVER_PASSWORD", false) + RotateServerPassword = rotateServerPasswordVal + cfg.RotateServerPassword = &rotateServerPasswordVal + ErrorChannelID = getString(cfg.ErrorChannelID, "ERROR_CHANNEL_ID", "") BackupKeepLastN = getInt(cfg.BackupKeepLastN, "BACKUP_KEEP_LAST_N", 2000) @@ -275,6 +284,10 @@ func applyConfig(cfg *JsonConfig) { IsConsoleEnabled = isConsoleEnabledVal cfg.IsConsoleEnabled = &isConsoleEnabledVal + isCLIDashboardEnabledVal := getBool(cfg.IsCLIDashboardEnabled, "IS_CLI_DASHBOARD_ENABLED", false) + IsCLIDashboardEnabled = isCLIDashboardEnabledVal + cfg.IsCLIDashboardEnabled = &isCLIDashboardEnabledVal + logClutterToConsoleVal := getBool(cfg.LogClutterToConsole, "LOG_CLUTTER_TO_CONSOLE", false) LogClutterToConsole = logClutterToConsoleVal cfg.LogClutterToConsole = &logClutterToConsoleVal @@ -283,6 +296,10 @@ func applyConfig(cfg *JsonConfig) { AutoStartServerOnStartup = autoStartServerOnStartupVal cfg.AutoStartServerOnStartup = &autoStartServerOnStartupVal + showExpertSettingsVal := getBool(cfg.ShowExpertSettings, "SHOW_EXPERT_SETTINGS", false) + ShowExpertSettings = showExpertSettingsVal + cfg.ShowExpertSettings = &showExpertSettingsVal + // Process SaveInfo to maintain backwards compatibility with pre-5.6.6 SaveInfo field (deprecated) if SaveInfo != "" { parts := strings.Split(SaveInfo, " ") @@ -345,9 +362,11 @@ func safeSaveConfig() error { LogChannelID: LogChannelID, SaveChannelID: SaveChannelID, ControlPanelChannelID: ControlPanelChannelID, + ServerInfoPanelChannelID: ServerInfoPanelChannelID, DiscordCharBufferSize: DiscordCharBufferSize, BlackListFilePath: BlackListFilePath, IsDiscordEnabled: &IsDiscordEnabled, + RotateServerPassword: &RotateServerPassword, ErrorChannelID: ErrorChannelID, BackupKeepLastN: BackupKeepLastN, IsCleanupEnabled: &IsCleanupEnabled, @@ -399,11 +418,13 @@ func safeSaveConfig() error { IsStationeersLaunchPadEnabled: &IsStationeersLaunchPadEnabled, IsStationeersLaunchPadAutoUpdatesEnabled: &IsStationeersLaunchPadAutoUpdatesEnabled, IsConsoleEnabled: &IsConsoleEnabled, + IsCLIDashboardEnabled: &IsCLIDashboardEnabled, LanguageSetting: LanguageSetting, AutoStartServerOnStartup: &AutoStartServerOnStartup, SSUIIdentifier: SSUIIdentifier, SSUIWebPort: SSUIWebPort, AdvertiserOverride: AdvertiserOverride, + ShowExpertSettings: &ShowExpertSettings, } file, err := os.Create(ConfigPath) diff --git a/src/config/getters.go b/src/config/getters.go index 16a12724..97a5b7c2 100644 --- a/src/config/getters.go +++ b/src/config/getters.go @@ -46,6 +46,12 @@ func GetControlPanelChannelID() string { return ControlPanelChannelID } +func GetServerInfoPanelChannelID() string { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return ServerInfoPanelChannelID +} + func GetDiscordCharBufferSize() int { ConfigMu.RLock() defer ConfigMu.RUnlock() @@ -64,6 +70,12 @@ func GetIsDiscordEnabled() bool { return IsDiscordEnabled } +func GetRotateServerPassword() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return RotateServerPassword +} + func GetErrorChannelID() string { ConfigMu.RLock() defer ConfigMu.RUnlock() @@ -109,6 +121,13 @@ func GetBackupCleanupInterval() time.Duration { return BackupCleanupInterval } +// GetBackupWaitTime returns the backup wait time in seconds. +func GetBackupWaitTime() time.Duration { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return BackupWaitTime +} + func GetIsNewTerrainAndSaveSystem() bool { ConfigMu.RLock() defer ConfigMu.RUnlock() @@ -375,6 +394,12 @@ func GetIsConsoleEnabled() bool { return IsConsoleEnabled } +func GetIsCLIDashboardEnabled() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return IsCLIDashboardEnabled +} + func GetLanguageSetting() string { ConfigMu.RLock() defer ConfigMu.RUnlock() @@ -399,6 +424,12 @@ func GetSSUIWebPort() string { return SSUIWebPort } +func GetShowExpertSettings() bool { + ConfigMu.RLock() + defer ConfigMu.RUnlock() + return ShowExpertSettings +} + // GetIsFirstTimeSetup returns the IsFirstTimeSetup func GetIsFirstTimeSetup() bool { ConfigMu.RLock() diff --git a/src/config/setters.go b/src/config/setters.go index 3e838375..d3767f7c 100644 --- a/src/config/setters.go +++ b/src/config/setters.go @@ -695,6 +695,14 @@ func SetIsConsoleEnabled(value bool) error { return safeSaveConfig() } +func SetIsCLIDashboardEnabled(value bool) error { + ConfigMu.Lock() + defer ConfigMu.Unlock() + + IsCLIDashboardEnabled = value + return safeSaveConfig() +} + func SetAllowAutoGameServerUpdates(value bool) error { ConfigMu.Lock() defer ConfigMu.Unlock() diff --git a/src/config/vars.go b/src/config/vars.go index e9f6f57b..f4d02c07 100644 --- a/src/config/vars.go +++ b/src/config/vars.go @@ -58,6 +58,7 @@ var ( SubsystemFilters []string AutoRestartServerTimer string IsConsoleEnabled bool + IsCLIDashboardEnabled bool LogClutterToConsole bool // surpresses clutter mono logs from the gameserver LanguageSetting string AutoStartServerOnStartup bool @@ -65,6 +66,7 @@ var ( AdvertiserOverride string IsStationeersLaunchPadEnabled bool IsStationeersLaunchPadAutoUpdatesEnabled bool + ShowExpertSettings bool ) // Runtime only variables @@ -80,19 +82,21 @@ var ( // Discord integration var ( - DiscordToken string - DiscordSession *discordgo.Session - IsDiscordEnabled bool - ControlChannelID string - StatusChannelID string - LogChannelID string - ErrorChannelID string - ConnectionListChannelID string - SaveChannelID string - ControlPanelChannelID string - DiscordCharBufferSize int - ExceptionMessageID string - BlackListFilePath string + DiscordToken string + DiscordSession *discordgo.Session + IsDiscordEnabled bool + RotateServerPassword bool + ControlChannelID string + StatusChannelID string + LogChannelID string + ErrorChannelID string + ConnectionListChannelID string + SaveChannelID string + ControlPanelChannelID string + ServerInfoPanelChannelID string + DiscordCharBufferSize int + ExceptionMessageID string + BlackListFilePath string ) // Backup and cleanup settings diff --git a/src/core/loader/afterstart.go b/src/core/loader/afterstart.go index 1e72fee7..1629525c 100644 --- a/src/core/loader/afterstart.go +++ b/src/core/loader/afterstart.go @@ -1,6 +1,8 @@ package loader import ( + "time" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/discordrpc" "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" @@ -26,11 +28,10 @@ func AfterStartComplete() { //setup.SetupAutostartScripts() discordrpc.StartDiscordRPC() - go func() { - //time.Sleep(10 * time.Second) - printStartupMessage() - if config.GetIsFirstTimeSetup() { - printFirstTimeSetupMessage() - } - }() + time.Sleep(500 * time.Millisecond) + printStartupMessage() + + if config.GetIsFirstTimeSetup() { + printFirstTimeSetupMessage() + } } diff --git a/src/core/loader/clisetup.go b/src/core/loader/clisetup.go new file mode 100644 index 00000000..d52319e3 --- /dev/null +++ b/src/core/loader/clisetup.go @@ -0,0 +1,73 @@ +// clisetup.go - Interactive CLI setup track for first-time configuration +package loader + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/mattn/go-isatty" +) + +// ANSI color codes +const ( + colorReset = "\033[0m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorCyan = "\033[36m" + colorPurple = "\033[35m" + colorBold = "\033[1m" +) + +// promptStyle adds consistent styling to prompts +func promptStyle(s string) string { + return colorPurple + colorBold + s + colorReset +} + +// IsInteractiveTerminal checks if we're running in an interactive terminal +func IsInteractiveTerminal() bool { + return isatty.IsTerminal(os.Stdin.Fd()) && isatty.IsTerminal(os.Stdout.Fd()) +} + +// PromptSetupTrackChoice asks the user which setup track they want to use +// Returns "cli", "web", or "skip" +func PromptSetupTrackChoice() string { + if !IsInteractiveTerminal() { + // Non-interactive, default to web silently + return "web" + } + + reader := bufio.NewReader(os.Stdin) + + fmt.Println() + fmt.Println(promptStyle(" " + colorBold + "SSUI First-Time Setup" + colorReset)) + fmt.Println(promptStyle(" How would you like to configure your server? ")) + fmt.Println(promptStyle(" ")) + fmt.Println(promptStyle(" [1] " + colorGreen + "CLI Setup" + colorReset + " - Configure right here in the terminal")) + fmt.Println(promptStyle(" [2] " + colorCyan + "Web Setup" + colorReset + " - Use the browser-based setup wizard")) + fmt.Println(promptStyle(" [3] " + colorYellow + "Skip" + colorReset + " - Use defaults and configure later")) + fmt.Println() + fmt.Print(promptStyle("\n Enter choice [1/2/3]: ")) + + input, err := reader.ReadString('\n') + if err != nil { + return "web" + } + + input = strings.TrimSpace(input) + switch input { + case "1", "cli", "CLI": + return "cli" + case "2", "web", "Web", "WEB": + return "web" + case "3", "skip", "Skip", "SKIP": + // Mark setup as complete so it doesn't prompt on web UI either + config.SetIsFirstTimeSetup(false) + return "skip" + default: + fmt.Println(colorCyan + " Invalid choice, defaulting to Web Setup..." + colorReset) + return "web" + } +} diff --git a/src/core/loader/helpers.go b/src/core/loader/helpers.go index fccb5e1b..a53265b2 100644 --- a/src/core/loader/helpers.go +++ b/src/core/loader/helpers.go @@ -91,12 +91,6 @@ func PrintConfigDetails(logLevel ...string) { // Backup Configuration backup := map[string]string{ - "BackupKeepLastN": fmt.Sprintf("%d", config.GetBackupKeepLastN()), - "IsCleanupEnabled": fmt.Sprintf("%v", config.GetIsCleanupEnabled()), - "BackupKeepDailyFor": fmt.Sprintf("%v", config.GetBackupKeepDailyFor()), - "BackupKeepWeeklyFor": fmt.Sprintf("%v", config.GetBackupKeepWeeklyFor()), - "BackupKeepMonthlyFor": fmt.Sprintf("%v", config.GetBackupKeepMonthlyFor()), - "BackupCleanupInterval": fmt.Sprintf("%v", config.GetBackupCleanupInterval()), "ConfiguredBackupDir": config.GetConfiguredBackupDir(), "ConfiguredSafeBackupDir": config.GetConfiguredSafeBackupDir(), } diff --git a/src/core/loader/terminalmsg.go b/src/core/loader/terminalmsg.go index 588eed6f..683b5dfe 100644 --- a/src/core/loader/terminalmsg.go +++ b/src/core/loader/terminalmsg.go @@ -40,12 +40,12 @@ func printStartupMessage() { func printFirstTimeSetupMessage() { // Setup guide + logger.Core.Cleanf("") logger.Core.Cleanf(" 📋 GETTING STARTED:") logger.Core.Cleanf(" ┌─────────────────────────────────────────────────────────────────────────────────────────────┐") logger.Core.Cleanf(" │ • Ready, set, go! Welcome to StationeersServerUI, new User! │") logger.Core.Cleanf(" │ • The good news: you made it here, which means you are likely ready to run your server! │") logger.Core.Cleanf(" │ • If this is your first time here, no worries: SSUI is made to be easy to use. │") - logger.Core.Cleanf(" │ • Configure your server by visiting the WebUI! │") logger.Core.Cleanf(" │ • Support is provided at https://discord.gg/8n3vN92MyJ │") logger.Core.Cleanf(" │ • For more details, check the GitHub Wiki: │") logger.Core.Cleanf(" │ • https://github.com/SteamServerUI/StationeersServerUI/v5/wiki │") diff --git a/src/discordbot/handleSlashcommands.go b/src/discordbot/handleSlashcommands.go index d2b0b9ff..88647a02 100644 --- a/src/discordbot/handleSlashcommands.go +++ b/src/discordbot/handleSlashcommands.go @@ -1,6 +1,7 @@ package discordbot import ( + "bytes" "fmt" "sort" "strconv" @@ -27,6 +28,7 @@ var handlers = map[string]commandHandler{ "help": handleHelp, "restore": handleRestore, "list": handleList, + "download": handleDownload, "bansteamid": handleBan, "unbansteamid": handleUnban, "update": handleUpdate, @@ -142,6 +144,7 @@ func handleHelp(s *discordgo.Session, i *discordgo.InteractionCreate, data Embed {Name: "/update", Value: "Updates the gameserver via SteamCMD"}, {Name: "/list [limit]", Value: "Lists recent backups (default: 5)"}, {Name: "/restore ", Value: "Restores a backup"}, + {Name: "/download [index]", Value: "Downloads a backup (most recent if no index)"}, {Name: "/bansteamid ", Value: "Bans a player"}, {Name: "/unbansteamid ", Value: "Unbans a player"}, {Name: "/command ", Value: "Sends a command to the gameserver console"}, @@ -174,6 +177,63 @@ func handleRestore(s *discordgo.Session, i *discordgo.InteractionCreate, data Em return nil } +const maxDiscordFileSize = 10 * 1024 * 1024 // 10MB Discord file upload limit + +func handleDownload(s *discordgo.Session, i *discordgo.InteractionCreate, data EmbedData) error { + index := -1 // -1 means most recent + + if len(i.ApplicationCommandData().Options) > 0 { + index = int(i.ApplicationCommandData().Options[0].IntValue()) + } + + // If no index provided, get the most recent backup index + if index == -1 { + backups, err := backupmgr.GlobalBackupManager.ListBackups(1) + if err != nil || len(backups) == 0 { + data.Title, data.Description = "Download Failed", "No backups available" + data.Fields = []EmbedField{{Name: "Error", Value: "Could not find any backups", Inline: true}} + return respond(s, i, data) + } + index = backups[0].Index + } + + data.Title, data.Description, data.Color = "📥 Backup Download", fmt.Sprintf("Preparing backup #%d for download...", index), 0xFFA500 + data.Fields = []EmbedField{{Name: "Status", Value: "🕛 Processing", Inline: true}} + if err := respond(s, i, data); err != nil { + return err + } + + sendBackupToChannel(s, i.ChannelID, index) + return nil +} + +func sendBackupToChannel(s *discordgo.Session, channelID string, index int) { + backupData, err := backupmgr.GlobalBackupManager.GetBackupFileData(index) + if err != nil { + s.ChannelMessageSend(channelID, fmt.Sprintf("❌ Failed to download backup #%d: %v", index, err)) + return + } + + if backupData.Size > maxDiscordFileSize { + s.ChannelMessageSend(channelID, fmt.Sprintf("❌ Backup #%d is too large to upload (%.2f MB > 10 MB limit)", index, float64(backupData.Size)/(1024*1024))) + return + } + + file := &discordgo.File{ + Name: backupData.Filename, + ContentType: "application/octet-stream", + Reader: bytes.NewReader(backupData.Data), + } + + _, err = s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Content: fmt.Sprintf("📦 Backup #%d (%s)", index, backupData.SaveTime.Format("Jan 2, 2006 3:04 PM")), + Files: []*discordgo.File{file}, + }) + if err != nil { + s.ChannelMessageSend(channelID, fmt.Sprintf("❌ Failed to upload backup #%d: %v", index, err)) + } +} + func handleList(s *discordgo.Session, i *discordgo.InteractionCreate, data EmbedData) error { limit := 5 if len(i.ApplicationCommandData().Options) > 0 { @@ -217,9 +277,27 @@ func handleList(s *discordgo.Session, i *discordgo.InteractionCreate, data Embed Color: 0xFFD700, Fields: fields, })) } + + // Add download buttons if showing 5 or fewer backups + var components []discordgo.MessageComponent + if len(backups) <= 5 { + var buttons []discordgo.MessageComponent + for _, b := range backups { + buttons = append(buttons, discordgo.Button{ + Label: fmt.Sprintf("📥 Download #%d", b.Index), + Style: discordgo.SecondaryButton, + CustomID: fmt.Sprintf("%s%d", ButtonDownloadBackupPfx, b.Index), + }) + } + components = append(components, discordgo.ActionsRow{Components: buttons}) + } + if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{Embeds: []*discordgo.MessageEmbed{embeds[0]}}, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embeds[0]}, + Components: components, + }, }); err != nil { return err } @@ -274,3 +352,46 @@ func handleCommand(s *discordgo.Session, i *discordgo.InteractionCreate, data Em } return nil } + +// handleDownloadButtonInteraction handles button interactions for downloading backups +func handleDownloadButtonInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.Type != discordgo.InteractionMessageComponent { + return + } + + customID := i.MessageComponentData().CustomID + if !strings.HasPrefix(customID, ButtonDownloadBackupPfx) { + return + } + + indexStr := strings.TrimPrefix(customID, ButtonDownloadBackupPfx) + index, err := strconv.Atoi(indexStr) + if err != nil { + respondToButtonError(s, i, "Invalid backup index") + return + } + + err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: fmt.Sprintf("📥 Preparing backup #%d for download...", index), + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + logger.Discord.Error("Error responding to download button: " + err.Error()) + return + } + + go sendBackupToChannel(s, config.GetControlChannelID(), index) +} + +func respondToButtonError(s *discordgo.Session, i *discordgo.InteractionCreate, message string) { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "❌ " + message, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/src/discordbot/interface.go b/src/discordbot/interface.go index 773f8f51..1c162efe 100644 --- a/src/discordbot/interface.go +++ b/src/discordbot/interface.go @@ -50,11 +50,14 @@ func InitializeDiscordBot() { // Register handlers and commands after session is open config.DiscordSession.AddHandler(listenToDiscordReactions) config.DiscordSession.AddHandler(listenToSlashCommands) + config.DiscordSession.AddHandler(handleServerInfoButtonInteraction) // Handle button interactions + config.DiscordSession.AddHandler(handleDownloadButtonInteraction) // Handle download button interactions registerSlashCommands(config.DiscordSession) logger.Discord.Info("Bot is now running.") SendMessageToStatusChannel("🤖 SSUI Version " + config.GetVersion() + " connected to Discord.") - sendControlPanel() // Send control panel message to Discord + sendControlPanel() // Send control panel message to Discord + sendServerInfoPanel() // Send server info panel with buttons to Discord UpdateBotStatusWithMessage("StationeersServerUI v" + config.GetVersion()) // Start buffer flush ticker BufferFlushTicker = time.NewTicker(5 * time.Second) diff --git a/src/discordbot/registerSlashcommands.go b/src/discordbot/registerSlashcommands.go index 8756d3ce..bf4ebbba 100644 --- a/src/discordbot/registerSlashcommands.go +++ b/src/discordbot/registerSlashcommands.go @@ -68,6 +68,18 @@ func registerSlashCommands(s *discordgo.Session) { }, }, }, + { + Name: "download", + Description: "Download a backup file (most recent if no index given)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "index", + Description: "Backup index to download (default: most recent)", + Required: false, + }, + }, + }, { Name: "bansteamid", Description: "Bans a player by their SteamID. Needs a Server restart to take effect.", diff --git a/src/discordbot/serverinfopanel.go b/src/discordbot/serverinfopanel.go new file mode 100644 index 00000000..a3b4beea --- /dev/null +++ b/src/discordbot/serverinfopanel.go @@ -0,0 +1,157 @@ +package discordbot + +import ( + "time" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" + "github.com/bwmarrin/discordgo" +) + +// ServerInfoMessageID stores the ID of the server info panel message +var ServerInfoMessageID string + +// Custom IDs for button interactions +const ( + ButtonGetPassword = "ssui_get_password" + ButtonDownloadBackupPfx = "ssui_download_backup_" // Prefix for download backup button. Has NOTHING TO DO WITH SERVERINFOPANEL, but this file felt like the best place to put it for now +) + +// sendServerInfoPanel sends the server info panel with interactive buttons +func sendServerInfoPanel() { + if !config.GetIsDiscordEnabled() { + return + } + + channelID := config.GetServerInfoPanelChannelID() + if channelID == "" { + logger.Discord.Debug("Server info panel channel ID not configured, skipping panel") + return + } + + serverName := config.GetServerName() + + // Create an embed for the server info panel + embed := &discordgo.MessageEmbed{ + Title: "🎮 Server Information", + Description: "Your Stationeers server is running as **" + serverName + "**", + Color: 0x5865F2, // Discord blurple color + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Server Name", + Value: serverName, + Inline: true, + }, + { + Name: "SSUI Version", + Value: config.GetVersion(), + Inline: true, + }, + }, + Timestamp: time.Now().Format(time.RFC3339), + } + + // Build message components (buttons in an action row) + var components []discordgo.MessageComponent + + components = append(components, discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "🔑 Get Current Server Password", + Style: discordgo.PrimaryButton, + CustomID: ButtonGetPassword, + }, + }, + }) + + // Send the message with embed and buttons + msg, err := config.DiscordSession.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{embed}, + Components: components, + }) + if err != nil { + logger.Discord.Error("Error sending server info panel: " + err.Error()) + return + } + + clearMessagesAboveLastN(channelID, 1) // Clear all old server info panel messages + ServerInfoMessageID = msg.ID + logger.Discord.Info("Server info panel sent successfully") +} + +// handleServerInfoButtonInteraction handles button interactions from the server info panel +func handleServerInfoButtonInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) { + // Only handle component interactions (buttons) + if i.Type != discordgo.InteractionMessageComponent { + return + } + + // Get the custom ID of the button that was clicked + customID := i.MessageComponentData().CustomID + + switch customID { + case ButtonGetPassword: + handleGetPasswordButton(s, i) + default: + // Not our button, ignore + return + } +} + +// handleGetPasswordButton sends the current server password as an ephemeral message (only visible to the user) +func handleGetPasswordButton(s *discordgo.Session, i *discordgo.InteractionCreate) { + + // Get the current server password from config + password := config.GetServerPassword() + + // Build the embed based on whether password is set + var embed *discordgo.MessageEmbed + if password == "" { + embed = &discordgo.MessageEmbed{ + Title: "🔓 No Password Set", + Description: "No password is currently configured for this server.", + Color: 0xFFA500, // Orange + Footer: &discordgo.MessageEmbedFooter{ + Text: "This message will disappear in 30 seconds", + }, + } + } else { + embed = &discordgo.MessageEmbed{ + Title: "🔑 Current Server Password", + Description: "Use this password to connect to the server.", + Color: 0x00FF00, // Green + Fields: []*discordgo.MessageEmbedField{ + { + Name: "Password", + Value: "```" + password + "```", + Inline: false, + }, + }, + Footer: &discordgo.MessageEmbedFooter{ + Text: "🔒 Only visible to you • Disappears in 30 seconds", + }, + Timestamp: time.Now().Format(time.RFC3339), + } + } + + // Respond with an ephemeral message (only visible to the user who clicked) + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + if err != nil { + logger.Discord.Error("Error responding to get password button: " + err.Error()) + return + } + + go func() { + time.Sleep(30 * time.Second) + err := s.InteractionResponseDelete(i.Interaction) + if err != nil { + logger.Discord.Debug("Could not delete ephemeral password message: " + err.Error()) + } + }() +} diff --git a/src/logger/logger.go b/src/logger/logger.go index 719c08c3..dd362aae 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,44 @@ 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() + fn := dashboardActiveFunc + dashboardHooksMutex.Unlock() + if fn == nil { + return false + } + return fn() +} + +// captureDashboardLog sends a log line to the dashboard for display +func captureDashboardLog(line string) { + dashboardHooksMutex.Lock() + fn := dashboardCaptureFunc + dashboardHooksMutex.Unlock() + if fn != nil { + fn(line) + } +} diff --git a/src/managers/backupmgr/backuphttp.go b/src/managers/backupmgr/backuphttp.go index 9fbc7cf9..e4b519ab 100644 --- a/src/managers/backupmgr/backuphttp.go +++ b/src/managers/backupmgr/backuphttp.go @@ -90,3 +90,45 @@ func (h *HTTPHandler) RestoreBackupHandler(w http.ResponseWriter, r *http.Reques w.Write([]byte("Server stopped & Backup restored successfully, Start the server to load the restored backup")) } + +// DownloadBackupRequest represents the JSON request for downloading a backup +type DownloadBackupRequest struct { + Index int `json:"index"` +} + +// DownloadBackupHandler handles requests to download a backup file +func (h *HTTPHandler) DownloadBackupHandler(w http.ResponseWriter, r *http.Request) { + logger.Web.Debug("Received backup download request") + + if r.Method != http.MethodPost { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use POST"}) + return + } + + var req DownloadBackupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid JSON request body"}) + return + } + + backupData, err := h.manager.GetBackupFileData(req.Index) + if err != nil { + w.Header().Set("Content-Type", "application/json") + if strings.Contains(err.Error(), "out of range") { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusInternalServerError) + } + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupData.Filename)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", backupData.Size)) + w.Write(backupData.Data) +} diff --git a/src/managers/backupmgr/manager.go b/src/managers/backupmgr/manager.go index 02a0a957..c1488f57 100644 --- a/src/managers/backupmgr/manager.go +++ b/src/managers/backupmgr/manager.go @@ -223,6 +223,38 @@ func (m *BackupManager) ListBackups(limit int) ([]BackupSaveFile, error) { return saves, nil } +// GetBackupFileData retrieves backup file data by index for download/transfer +func (m *BackupManager) GetBackupFileData(index int) (*BackupFileData, error) { + m.mu.Lock() + defer m.mu.Unlock() + + saves, err := m.getBackupSaveFiles() + if err != nil { + return nil, fmt.Errorf("failed to get backup files: %w", err) + } + + if index < 0 || index >= len(saves) { + return nil, fmt.Errorf("backup index %d out of range (0-%d)", index, len(saves)-1) + } + + targetSave := saves[index] + filePath := targetSave.SaveFile + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read backup file: %w", err) + } + + filename := filepath.Base(filePath) + + return &BackupFileData{ + Data: data, + Filename: filename, + Size: int64(len(data)), + SaveTime: targetSave.SaveTime, + }, nil +} + // Shutdown stops all backup operations func (m *BackupManager) Shutdown() { logger.Backup.Debug("Shutting down previous backup manager...") diff --git a/src/managers/backupmgr/types.go b/src/managers/backupmgr/types.go index 721b861c..842fea9c 100644 --- a/src/managers/backupmgr/types.go +++ b/src/managers/backupmgr/types.go @@ -35,6 +35,14 @@ type BackupSaveFile struct { SaveTime time.Time } +// BackupFileData contains the backup file bytes and metadata for download/transfer +type BackupFileData struct { + Data []byte + Filename string + Size int64 + SaveTime time.Time +} + // BackupManager manages backup operations type BackupManager struct { config BackupConfig diff --git a/src/managers/gamemgr/passwordrotation.go b/src/managers/gamemgr/passwordrotation.go new file mode 100644 index 00000000..bfd2d2c7 --- /dev/null +++ b/src/managers/gamemgr/passwordrotation.go @@ -0,0 +1,34 @@ +// passwordrotation.go +package gamemgr + +import ( + "math/rand" + "strconv" + "time" + + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/config" + "github.com/JacksonTheMaster/StationeersServerUI/v5/src/logger" +) + +// rotatePasswordIfEnabled checks if password rotation is enabled and sets a new random password +func rotatePasswordIfEnabled() { + + if !config.GetIsDiscordEnabled() || config.GetServerInfoPanelChannelID() == "" || !config.GetRotateServerPassword() { + return + } + + // Seed the random number generator + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + + // Generate a random 6-digit password + newPassword := strconv.Itoa(rng.Intn(900000) + 100000) + + // Set the new password in config + err := config.SetServerPassword(newPassword) + if err != nil { + logger.Core.Error("Failed to set rotated server password: " + err.Error()) + return + } + + logger.Core.Info("Password rotation enabled - new server password set: " + newPassword) +} diff --git a/src/managers/gamemgr/processmanagement.go b/src/managers/gamemgr/processmanagement.go index 280eba9c..7a754648 100644 --- a/src/managers/gamemgr/processmanagement.go +++ b/src/managers/gamemgr/processmanagement.go @@ -33,6 +33,9 @@ func InternalStartServer() error { return fmt.Errorf("server is already running") } + // Rotate password if enabled (sets new random password before building args) + rotatePasswordIfEnabled() + args := buildCommandArgs() logger.Core.Info("=== GAMESERVER STARTING ===") @@ -129,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 @@ -219,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 c39755bb..3e0af67b 100644 --- a/src/web/configpage.go +++ b/src/web/configpage.go @@ -126,6 +126,96 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { isStationeersLaunchPadEnabled = "true" } + rotateServerPasswordTrueSelected := "" + rotateServerPasswordFalseSelected := "" + if config.GetRotateServerPassword() { + rotateServerPasswordTrueSelected = "selected" + } else { + rotateServerPasswordFalseSelected = "selected" + } + + // Expert Settings toggle + showExpertSettingsTrueSelected := "" + showExpertSettingsFalseSelected := "" + if config.GetShowExpertSettings() { + showExpertSettingsTrueSelected = "selected" + } else { + showExpertSettingsFalseSelected = "selected" + } + + // Expert Settings booleans + debugTrueSelected := "" + debugFalseSelected := "" + if config.GetIsDebugMode() { + debugTrueSelected = "selected" + } else { + debugFalseSelected = "selected" + } + + logClutterToConsoleTrueSelected := "" + logClutterToConsoleFalseSelected := "" + if config.GetLogClutterToConsole() { + logClutterToConsoleTrueSelected = "selected" + } else { + logClutterToConsoleFalseSelected = "selected" + } + + isSSCMEnabledTrueSelected := "" + isSSCMEnabledFalseSelected := "" + if config.GetIsSSCMEnabled() { + isSSCMEnabledTrueSelected = "selected" + } else { + isSSCMEnabledFalseSelected = "selected" + } + + isConsoleEnabledTrueSelected := "" + isConsoleEnabledFalseSelected := "" + if config.GetIsConsoleEnabled() { + isConsoleEnabledTrueSelected = "selected" + } else { + isConsoleEnabledFalseSelected = "selected" + } + + isUpdateEnabledTrueSelected := "" + isUpdateEnabledFalseSelected := "" + if config.GetIsUpdateEnabled() { + isUpdateEnabledTrueSelected = "selected" + } else { + isUpdateEnabledFalseSelected = "selected" + } + + allowPrereleaseUpdatesTrueSelected := "" + allowPrereleaseUpdatesFalseSelected := "" + if config.GetAllowPrereleaseUpdates() { + allowPrereleaseUpdatesTrueSelected = "selected" + } else { + allowPrereleaseUpdatesFalseSelected = "selected" + } + + allowMajorUpdatesTrueSelected := "" + allowMajorUpdatesFalseSelected := "" + if config.GetAllowMajorUpdates() { + allowMajorUpdatesTrueSelected = "selected" + } else { + allowMajorUpdatesFalseSelected = "selected" + } + + authEnabledTrueSelected := "" + authEnabledFalseSelected := "" + if config.GetAuthEnabled() { + authEnabledTrueSelected = "selected" + } else { + authEnabledFalseSelected = "selected" + } + + isStationeersLaunchPadAutoUpdatesEnabledTrueSelected := "" + isStationeersLaunchPadAutoUpdatesEnabledFalseSelected := "" + if config.GetIsStationeersLaunchPadAutoUpdatesEnabled() { + isStationeersLaunchPadAutoUpdatesEnabledTrueSelected = "selected" + } else { + isStationeersLaunchPadAutoUpdatesEnabledFalseSelected = "selected" + } + data := ConfigTemplateData{ // Config values DiscordToken: config.GetDiscordToken(), @@ -135,11 +225,15 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { LogChannelID: config.GetLogChannelID(), SaveChannelID: config.GetSaveChannelID(), ControlPanelChannelID: config.GetControlPanelChannelID(), + ServerInfoPanelChannelID: config.GetServerInfoPanelChannelID(), BlackListFilePath: config.GetBlackListFilePath(), ErrorChannelID: config.GetErrorChannelID(), IsDiscordEnabled: fmt.Sprintf("%v", config.GetIsDiscordEnabled()), IsDiscordEnabledTrueSelected: discordTrueSelected, IsDiscordEnabledFalseSelected: discordFalseSelected, + RotateServerPassword: fmt.Sprintf("%v", config.GetRotateServerPassword()), + RotateServerPasswordTrueSelected: rotateServerPasswordTrueSelected, + RotateServerPasswordFalseSelected: rotateServerPasswordFalseSelected, GameBranch: config.GetGameBranch(), Difficulty: config.GetDifficulty(), StartCondition: config.GetStartCondition(), @@ -192,7 +286,9 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { CreateGameServerLogFileFalseSelected: createGameServerLogFileFalseSelected, // Localized UI text + UIText_ConfigHeadline: localization.GetString("UIText_ConfigHeadline"), UIText_ServerConfig: localization.GetString("UIText_ServerConfig"), + UIText_BackToDashboard: localization.GetString("UIText_BackToDashboard"), UIText_DiscordIntegration: localization.GetString("UIText_DiscordIntegration"), UIText_SLPModIntegration: localization.GetString("UIText_SLPModIntegration"), UIText_DetectionManager: localization.GetString("UIText_DetectionManager"), @@ -246,6 +342,8 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { UIText_ServerExePathInfo2: localization.GetString("UIText_ServerExePathInfo2"), UIText_AdditionalParams: localization.GetString("UIText_AdditionalParams"), UIText_AdditionalParamsInfo: localization.GetString("UIText_AdditionalParamsInfo"), + UIText_ShowExpertSettings: localization.GetString("UIText_ShowExpertSettings"), + UIText_ShowExpertSettingsInfo: localization.GetString("UIText_ShowExpertSettingsInfo"), UIText_AutoRestartServerTimer: localization.GetString("UIText_AutoRestartServerTimer"), UIText_AutoRestartServerTimerInfo: localization.GetString("UIText_AutoRestartServerTimerInfo"), UIText_GameBranch: localization.GetString("UIText_GameBranch"), @@ -276,6 +374,10 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { UIText_AdminCommandChannelInfo: localization.GetString("UIText_AdminCommandChannelInfo"), UIText_ControlPanelChannel: localization.GetString("UIText_ControlPanelChannel"), UIText_ControlPanelChannelInfo: localization.GetString("UIText_ControlPanelChannelInfo"), + UIText_ServerInfoPanelChannel: localization.GetString("UIText_ServerInfoPanelChannel"), + UIText_ServerInfoPanelChannelInfo: localization.GetString("UIText_ServerInfoPanelChannelInfo"), + UIText_RotateServerPassword: localization.GetString("UIText_RotateServerPassword"), + UIText_RotateServerPasswordInfo: localization.GetString("UIText_RotateServerPasswordInfo"), UIText_StatusChannel: localization.GetString("UIText_StatusChannel"), UIText_StatusChannelInfo: localization.GetString("UIText_StatusChannelInfo"), UIText_ConnectionListChannel: localization.GetString("UIText_ConnectionListChannel"), @@ -321,6 +423,45 @@ func ServeConfigPage(w http.ResponseWriter, r *http.Request) { UIText_SLP_InstalledMods: localization.GetString("UIText_SLP_InstalledMods"), IsStationeersLaunchPadEnabled: isStationeersLaunchPadEnabled, + + // Expert Settings + ShowExpertSettings: fmt.Sprintf("%v", config.GetShowExpertSettings()), + ShowExpertSettingsTrueSelected: showExpertSettingsTrueSelected, + 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()), + AdvertiserOverride: config.GetAdvertiserOverride(), + IsStationeersLaunchPadAutoUpdatesEnabled: fmt.Sprintf("%v", config.GetIsStationeersLaunchPadAutoUpdatesEnabled()), + IsStationeersLaunchPadAutoUpdatesEnabledTrueSelected: isStationeersLaunchPadAutoUpdatesEnabledTrueSelected, + IsStationeersLaunchPadAutoUpdatesEnabledFalseSelected: isStationeersLaunchPadAutoUpdatesEnabledFalseSelected, } err = tmpl.Execute(w, data) diff --git a/src/web/routes.go b/src/web/routes.go index 0011c0ea..94ab1fed 100644 --- a/src/web/routes.go +++ b/src/web/routes.go @@ -58,6 +58,7 @@ func SetupRoutes() (*http.ServeMux, *http.ServeMux) { backupHandler := backupmgr.NewHTTPHandler(backupmgr.GlobalBackupManager) protectedMux.HandleFunc("/api/v2/backups", backupHandler.ListBackupsHandler) protectedMux.HandleFunc("/api/v2/backups/restore", backupHandler.RestoreBackupHandler) + protectedMux.HandleFunc("/api/v2/backups/download", backupHandler.DownloadBackupHandler) // Configuration protectedMux.HandleFunc("/saveconfigasjson", configchanger.SaveConfigForm) // legacy, used on config page diff --git a/src/web/templatevars.go b/src/web/templatevars.go index dc1a9ea5..b274ea84 100644 --- a/src/web/templatevars.go +++ b/src/web/templatevars.go @@ -35,11 +35,15 @@ type ConfigTemplateData struct { LogChannelID string SaveChannelID string ControlPanelChannelID string + ServerInfoPanelChannelID string BlackListFilePath string ErrorChannelID string IsDiscordEnabled string IsDiscordEnabledTrueSelected string IsDiscordEnabledFalseSelected string + RotateServerPassword string + RotateServerPasswordTrueSelected string + RotateServerPasswordFalseSelected string GameBranch string Difficulty string StartCondition string @@ -92,7 +96,9 @@ type ConfigTemplateData struct { CreateGameServerLogFileTrueSelected string CreateGameServerLogFileFalseSelected string + UIText_ConfigHeadline string UIText_ServerConfig string + UIText_BackToDashboard string UIText_DiscordIntegration string UIText_SLPModIntegration string UIText_DetectionManager string @@ -146,6 +152,8 @@ type ConfigTemplateData struct { UIText_ServerExePathInfo2 string UIText_AdditionalParams string UIText_AdditionalParamsInfo string + UIText_ShowExpertSettings string + UIText_ShowExpertSettingsInfo string UIText_AutoRestartServerTimer string UIText_AutoRestartServerTimerInfo string UIText_GameBranch string @@ -178,6 +186,10 @@ type ConfigTemplateData struct { UIText_AdminCommandChannelInfo string UIText_ControlPanelChannel string UIText_ControlPanelChannelInfo string + UIText_ServerInfoPanelChannel string + UIText_ServerInfoPanelChannelInfo string + UIText_RotateServerPassword string + UIText_RotateServerPasswordInfo string UIText_StatusChannel string UIText_StatusChannelInfo string UIText_ConnectionListChannel string @@ -223,4 +235,52 @@ type ConfigTemplateData struct { UIText_SLP_InstalledMods string IsStationeersLaunchPadEnabled string + + // Expert Settings + ShowExpertSettings string + ShowExpertSettingsTrueSelected string + 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 + IsStationeersLaunchPadAutoUpdatesEnabledTrueSelected string + IsStationeersLaunchPadAutoUpdatesEnabledFalseSelected string }