From 44da16977cb8803de93c88656b2bf5fdd9de0af8 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Wed, 14 Jan 2026 21:47:17 +0100 Subject: [PATCH] Implemented a real-time console log streaming via WebSocket for debugging purposes. Users can enable console output in settings to view application logs directly in the web interface. --- cmd/server/main.go | 9 ++ internal/config/settings.go | 28 +++++ internal/locales/de.json | 4 + internal/locales/de_ch.json | 4 + internal/locales/en.json | 4 + internal/locales/es.json | 4 + internal/locales/fr.json | 4 + internal/locales/it.json | 4 + internal/storage/storage.go | 30 +++-- pkg/web/console_logger.go | 105 ++++++++++++++++ pkg/web/static/fail2ban-ui.css | 5 + pkg/web/static/js/console.js | 213 +++++++++++++++++++++++++++++++++ pkg/web/static/js/settings.js | 10 ++ pkg/web/static/js/websocket.js | 41 ++++++- pkg/web/templates/index.html | 31 ++++- pkg/web/websocket.go | 20 ++++ 16 files changed, 501 insertions(+), 15 deletions(-) create mode 100644 pkg/web/console_logger.go create mode 100644 pkg/web/static/js/console.js diff --git a/cmd/server/main.go b/cmd/server/main.go index 248748e..9e7ea20 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -82,6 +82,15 @@ func main() { wsHub := web.NewHub() go wsHub.Run() + // Setup console log writer to capture stdout and send via WebSocket + web.SetupConsoleLogWriter(wsHub) + // Update enabled state based on current settings + web.UpdateConsoleLogEnabled() + // Register callback to update console log state when settings change + config.SetUpdateConsoleLogStateFunc(func(enabled bool) { + web.SetConsoleLogEnabled(enabled) + }) + // Register all application routes, including the static files and templates. web.RegisterRoutes(router, wsHub) diff --git a/internal/config/settings.go b/internal/config/settings.go index 9f573e2..49217a5 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -82,6 +82,9 @@ type AppSettings struct { // Email alert preferences EmailAlertsForBans bool `json:"emailAlertsForBans"` // Enable email alerts for ban events (default: true) EmailAlertsForUnbans bool `json:"emailAlertsForUnbans"` // Enable email alerts for unban events (default: false) + + // Console output preferences + ConsoleOutput bool `json:"consoleOutput"` // Enable console output in web UI (default: false) } type AdvancedActionsConfig struct { @@ -425,6 +428,7 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) { currentSettings.CallbackSecret = rec.CallbackSecret currentSettings.EmailAlertsForBans = rec.EmailAlertsForBans currentSettings.EmailAlertsForUnbans = rec.EmailAlertsForUnbans + currentSettings.ConsoleOutput = rec.ConsoleOutput } func applyServerRecordsLocked(records []storage.ServerRecord) { @@ -511,6 +515,8 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) { GeoIPProvider: currentSettings.GeoIPProvider, GeoIPDatabasePath: currentSettings.GeoIPDatabasePath, MaxLogLines: currentSettings.MaxLogLines, + // Console output settings + ConsoleOutput: currentSettings.ConsoleOutput, }, nil } @@ -1529,9 +1535,31 @@ func UpdateSettings(new AppSettings) (AppSettings, error) { } DebugLog("New settings applied: %v", currentSettings) // Log settings applied + // Update console log enabled state if it changed + if old.ConsoleOutput != new.ConsoleOutput { + // Import web package to update console log state + // We'll handle this via a callback or direct call + updateConsoleLogState(new.ConsoleOutput) + } + if err := persistAllLocked(); err != nil { fmt.Println("Error saving settings:", err) return currentSettings, err } return currentSettings, nil } + +// updateConsoleLogState updates the console log writer enabled state +// This is called from UpdateSettings when console output setting changes +var updateConsoleLogStateFunc func(bool) + +// SetUpdateConsoleLogStateFunc sets the callback function to update console log state +func SetUpdateConsoleLogStateFunc(fn func(bool)) { + updateConsoleLogStateFunc = fn +} + +func updateConsoleLogState(enabled bool) { + if updateConsoleLogStateFunc != nil { + updateConsoleLogStateFunc(enabled) + } +} diff --git a/internal/locales/de.json b/internal/locales/de.json index 6dff9a9..5073bda 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -115,6 +115,10 @@ "settings.port_env_hint": "Um den Port über die Weboberfläche zu ändern, entfernen Sie die PORT-Umgebungsvariable und starten Sie den Container neu.", "settings.port_restart_hint": "⚠️ Port-Änderungen erfordern einen Neustart des Containers, um wirksam zu werden.", "settings.enable_debug": "Debug-Protokoll aktivieren", + "settings.enable_console": "Konsolenausgabe aktivieren", + "settings.console.title": "Konsolenausgabe", + "settings.console.clear": "Löschen", + "settings.console.save_hint": "Bitte speichern Sie zuerst Ihre Einstellungen, bevor hier Logs angezeigt werden.", "settings.alert": "Alarm-Einstellungen", "settings.callback_url": "Fail2ban Callback-URL", "settings.callback_url_placeholder": "http://127.0.0.1:8080", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index 2b72a67..63c0356 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -115,6 +115,10 @@ "settings.port_env_hint": "Um de Port über d Weboberflächi z ändere, entferne d PORT-Umgebigsvariable und start de Container neu.", "settings.port_restart_hint": "⚠️ Port-Änderige erfordere ä Neustart vom Container, zum wirksam z werde.", "settings.enable_debug": "Debug-Modus aktivierä", + "settings.enable_console": "Konsolenusgab aktivierä", + "settings.console.title": "Konsolenusgab", + "settings.console.clear": "Löschä", + "settings.console.save_hint": "Bitte speichere zerscht dini Istellige, bevor hiä Logs azeigt wärde.", "settings.alert": "Alarm-Istellige", "settings.callback_url": "Fail2ban Callback-URL", "settings.callback_url_placeholder": "http://127.0.0.1:8080", diff --git a/internal/locales/en.json b/internal/locales/en.json index 3cd1d3e..e21f5a4 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -115,6 +115,10 @@ "settings.port_env_hint": "To change the port via Web UI, remove the PORT environment variable and restart the container.", "settings.port_restart_hint": "⚠️ Port changes require a container restart to take effect.", "settings.enable_debug": "Enable Debug Log", + "settings.enable_console": "Enable Console Output", + "settings.console.title": "Console Output", + "settings.console.clear": "Clear", + "settings.console.save_hint": "Please save your settings first before logs will be displayed here.", "settings.alert": "Alert Settings", "settings.callback_url": "Fail2ban Callback URL", "settings.callback_url_placeholder": "http://127.0.0.1:8080", diff --git a/internal/locales/es.json b/internal/locales/es.json index fa5aacb..5de9b1e 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -115,6 +115,10 @@ "settings.port_env_hint": "Para cambiar el puerto mediante la interfaz web, elimine la variable de entorno PORT y reinicie el contenedor.", "settings.port_restart_hint": "⚠️ Los cambios de puerto requieren un reinicio del contenedor para surtir efecto.", "settings.enable_debug": "Habilitar el modo de depuración", + "settings.enable_console": "Habilitar salida de consola", + "settings.console.title": "Salida de consola", + "settings.console.clear": "Limpiar", + "settings.console.save_hint": "Por favor, guarde primero su configuración antes de que se muestren los registros aquí.", "settings.alert": "Configuración de alertas", "settings.callback_url": "URL de retorno de Fail2ban", "settings.callback_url_placeholder": "http://127.0.0.1:8080", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index 32de275..0f7224b 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -115,6 +115,10 @@ "settings.port_env_hint": "Pour modifier le port via l'interface Web, supprimez la variable d'environnement PORT et redémarrez le conteneur.", "settings.port_restart_hint": "⚠️ Les modifications du port nécessitent un redémarrage du conteneur pour prendre effet.", "settings.enable_debug": "Activer le mode débogage", + "settings.enable_console": "Activer la sortie console", + "settings.console.title": "Sortie console", + "settings.console.clear": "Effacer", + "settings.console.save_hint": "Veuillez d'abord enregistrer vos paramètres avant que les journaux ne s'affichent ici.", "settings.alert": "Paramètres d'alerte", "settings.callback_url": "URL de rappel Fail2ban", "settings.callback_url_placeholder": "http://127.0.0.1:8080", diff --git a/internal/locales/it.json b/internal/locales/it.json index 5a8a045..3c38c60 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -115,6 +115,10 @@ "settings.port_env_hint": "Per modificare la porta tramite l'interfaccia Web, rimuovere la variabile d'ambiente PORT e riavviare il contenitore.", "settings.port_restart_hint": "⚠️ Le modifiche alla porta richiedono un riavvio del contenitore per avere effetto.", "settings.enable_debug": "Abilita debug", + "settings.enable_console": "Abilita output console", + "settings.console.title": "Output console", + "settings.console.clear": "Pulisci", + "settings.console.save_hint": "Si prega di salvare prima le impostazioni prima che i log vengano visualizzati qui.", "settings.alert": "Impostazioni di allarme", "settings.callback_url": "URL di callback Fail2ban", "settings.callback_url_placeholder": "http://127.0.0.1:8080", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 06e4966..9cef60f 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -60,6 +60,8 @@ type AppSettingsRecord struct { AlertCountriesJSON string EmailAlertsForBans bool EmailAlertsForUnbans bool + // Console output settings + ConsoleOutput bool // SMTP settings SMTPHost string SMTPPort int @@ -190,17 +192,17 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) { } row := db.QueryRowContext(ctx, ` -SELECT language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines +SELECT language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines, console_output FROM app_settings WHERE id = 1`) var ( lang, callback, callbackSecret, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions, geoipProvider, geoipDatabasePath sql.NullString port, smtpPort, maxretry, maxLogLines sql.NullInt64 - debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn, emailAlertsForBans, emailAlertsForUnbans sql.NullInt64 + debug, restartNeeded, smtpTLS, bantimeInc, defaultJailEn, emailAlertsForBans, emailAlertsForUnbans, consoleOutput sql.NullInt64 ) - err := row.Scan(&lang, &port, &debug, &restartNeeded, &callback, &callbackSecret, &alerts, &emailAlertsForBans, &emailAlertsForUnbans, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &defaultJailEn, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &banaction, &banactionAllports, &advancedActions, &geoipProvider, &geoipDatabasePath, &maxLogLines) + err := row.Scan(&lang, &port, &debug, &restartNeeded, &callback, &callbackSecret, &alerts, &emailAlertsForBans, &emailAlertsForUnbans, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &defaultJailEn, &ignoreIP, &bantime, &findtime, &maxretry, &destemail, &banaction, &banactionAllports, &advancedActions, &geoipProvider, &geoipDatabasePath, &maxLogLines, &consoleOutput) if errors.Is(err, sql.ErrNoRows) { return AppSettingsRecord{}, false, nil } @@ -243,6 +245,8 @@ WHERE id = 1`) GeoIPProvider: stringFromNull(geoipProvider), GeoIPDatabasePath: stringFromNull(geoipDatabasePath), MaxLogLines: intFromNull(maxLogLines), + // Console output settings + ConsoleOutput: intToBool(intFromNull(consoleOutput)), } return rec, true, nil @@ -254,9 +258,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error { } _, err := db.ExecContext(ctx, ` INSERT INTO app_settings ( - id, language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines + id, language, port, debug, restart_needed, callback_url, callback_secret, alert_countries, email_alerts_for_bans, email_alerts_for_unbans, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, default_jail_enable, ignore_ip, bantime, findtime, maxretry, destemail, banaction, banaction_allports, advanced_actions, geoip_provider, geoip_database_path, max_log_lines, console_output ) VALUES ( - 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT(id) DO UPDATE SET language = excluded.language, port = excluded.port, @@ -285,7 +289,8 @@ INSERT INTO app_settings ( advanced_actions = excluded.advanced_actions, geoip_provider = excluded.geoip_provider, geoip_database_path = excluded.geoip_database_path, - max_log_lines = excluded.max_log_lines + max_log_lines = excluded.max_log_lines, + console_output = excluded.console_output `, rec.Language, rec.Port, boolToInt(rec.Debug), @@ -314,7 +319,7 @@ INSERT INTO app_settings ( rec.GeoIPProvider, rec.GeoIPDatabasePath, rec.MaxLogLines, - ) + boolToInt(rec.ConsoleOutput)) return err } @@ -861,7 +866,9 @@ CREATE TABLE IF NOT EXISTS app_settings ( advanced_actions TEXT, geoip_provider TEXT, geoip_database_path TEXT, - max_log_lines INTEGER + max_log_lines INTEGER, + -- Console output settings + console_output INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS servers ( @@ -933,6 +940,13 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status); // return err // } // } + + // Migration: Add console_output column if it doesn't exist + if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN console_output INTEGER DEFAULT 0`); err != nil { + if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") { + return err + } + } _ = strings.Contains // Keep strings import for migration example above return nil diff --git a/pkg/web/console_logger.go b/pkg/web/console_logger.go new file mode 100644 index 0000000..a2f6458 --- /dev/null +++ b/pkg/web/console_logger.go @@ -0,0 +1,105 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package web + +import ( + "io" + "log" + "os" + "sync" + + "github.com/swissmakers/fail2ban-ui/internal/config" +) + +// ConsoleLogWriter is a multi-writer that writes to both the original log output +// and broadcasts to WebSocket clients when console output is enabled +type ConsoleLogWriter struct { + originalWriter io.Writer + hub *Hub + mu sync.RWMutex + enabled bool +} + +// NewConsoleLogWriter creates a new console log writer +func NewConsoleLogWriter(hub *Hub, originalWriter io.Writer) *ConsoleLogWriter { + return &ConsoleLogWriter{ + originalWriter: originalWriter, + hub: hub, + enabled: false, + } +} + +// SetEnabled enables or disables console output broadcasting +func (c *ConsoleLogWriter) SetEnabled(enabled bool) { + c.mu.Lock() + defer c.mu.Unlock() + c.enabled = enabled +} + +// Write implements io.Writer interface +func (c *ConsoleLogWriter) Write(p []byte) (n int, err error) { + // Always write to original writer + n, err = c.originalWriter.Write(p) + + // Broadcast to WebSocket if enabled + c.mu.RLock() + enabled := c.enabled + c.mu.RUnlock() + + if enabled && c.hub != nil { + // Remove trailing newline for cleaner display + message := string(p) + if len(message) > 0 && message[len(message)-1] == '\n' { + message = message[:len(message)-1] + } + if len(message) > 0 { + c.hub.BroadcastConsoleLog(message) + } + } + + return n, err +} + +var globalConsoleLogWriter *ConsoleLogWriter +var consoleLogWriterOnce sync.Once + +// SetupConsoleLogWriter sets up the console log writer and replaces the standard log output +// This captures all log.Printf, log.Println, etc. output +func SetupConsoleLogWriter(hub *Hub) { + consoleLogWriterOnce.Do(func() { + // Create a multi-writer that writes to both original stdout and our console writer + globalConsoleLogWriter = NewConsoleLogWriter(hub, os.Stdout) + + // Replace log output - this captures all log.Printf, log.Println, etc. + log.SetOutput(globalConsoleLogWriter) + }) +} + +// UpdateConsoleLogEnabled updates the enabled state based on settings +func UpdateConsoleLogEnabled() { + if globalConsoleLogWriter != nil { + settings := config.GetSettings() + globalConsoleLogWriter.SetEnabled(settings.ConsoleOutput) + } +} + +// SetConsoleLogEnabled directly sets the enabled state +func SetConsoleLogEnabled(enabled bool) { + if globalConsoleLogWriter != nil { + globalConsoleLogWriter.SetEnabled(enabled) + } +} diff --git a/pkg/web/static/fail2ban-ui.css b/pkg/web/static/fail2ban-ui.css index 706ed29..f8b1cb8 100644 --- a/pkg/web/static/fail2ban-ui.css +++ b/pkg/web/static/fail2ban-ui.css @@ -462,6 +462,11 @@ button.bg-red-500:hover, button.bg-red-600:hover { color: rgb(107 114 128); } +/* Console output text colors */ +.text-gray-100 { + color: #f3f4f6; +} + #wsTooltip .border-gray-700 { border-color: rgb(55 65 81); } diff --git a/pkg/web/static/js/console.js b/pkg/web/static/js/console.js new file mode 100644 index 0000000..519bc54 --- /dev/null +++ b/pkg/web/static/js/console.js @@ -0,0 +1,213 @@ +// Console Output Handler for Fail2ban UI +"use strict"; + +let consoleOutputContainer = null; +let consoleOutputElement = null; +let maxConsoleLines = 1000; // Maximum number of lines to keep in console +let wasConsoleEnabledOnLoad = false; // Track if console was enabled when page loaded + +function initConsoleOutput() { + consoleOutputContainer = document.getElementById('consoleOutputContainer'); + consoleOutputElement = document.getElementById('consoleOutputWindow'); + + if (!consoleOutputContainer || !consoleOutputElement) { + return; + } + + // Register WebSocket callback for console logs + if (typeof wsManager !== 'undefined' && wsManager) { + wsManager.onConsoleLog(function(message, timestamp) { + appendConsoleLog(message, timestamp); + }); + } else { + // Wait for WebSocket manager to be available + const wsCheckInterval = setInterval(function() { + if (typeof wsManager !== 'undefined' && wsManager) { + wsManager.onConsoleLog(function(message, timestamp) { + appendConsoleLog(message, timestamp); + }); + clearInterval(wsCheckInterval); + } + }, 100); + + // Stop checking after 5 seconds + setTimeout(function() { + clearInterval(wsCheckInterval); + }, 5000); + } +} + +function toggleConsoleOutput(userClicked) { + const checkbox = document.getElementById('consoleOutput'); + const container = document.getElementById('consoleOutputContainer'); + + if (!checkbox || !container) { + return; + } + + if (checkbox.checked) { + container.classList.remove('hidden'); + // Initialize console if not already done + if (!consoleOutputElement) { + initConsoleOutput(); + } else { + // Re-register WebSocket callback in case it wasn't registered before + if (typeof wsManager !== 'undefined' && wsManager) { + // Remove any existing callbacks and re-register + if (!wsManager.consoleLogCallbacks) { + wsManager.consoleLogCallbacks = []; + } + // Check if callback already exists + let callbackExists = false; + for (let i = 0; i < wsManager.consoleLogCallbacks.length; i++) { + if (wsManager.consoleLogCallbacks[i].toString().includes('appendConsoleLog')) { + callbackExists = true; + break; + } + } + if (!callbackExists) { + wsManager.onConsoleLog(function(message, timestamp) { + appendConsoleLog(message, timestamp); + }); + } + } + } + + // Show save hint only if user just clicked to enable (not on page load) + const consoleEl = document.getElementById('consoleOutputWindow'); + if (consoleEl && userClicked && !wasConsoleEnabledOnLoad) { + // Clear initial placeholder message + const placeholder = consoleEl.querySelector('.text-gray-500'); + if (placeholder && placeholder.textContent === 'Console output will appear here...') { + placeholder.remove(); + } + + // Show save hint message + const hintDiv = document.createElement('div'); + hintDiv.className = 'text-yellow-400 italic text-center py-4'; + hintDiv.id = 'consoleSaveHint'; + const hintText = typeof t !== 'undefined' ? t('settings.console.save_hint', 'Please save your settings first before logs will be displayed here.') : 'Please save your settings first before logs will be displayed here.'; + hintDiv.textContent = hintText; + consoleEl.appendChild(hintDiv); + } else if (consoleEl) { + // Just clear initial placeholder if it exists + const placeholder = consoleEl.querySelector('.text-gray-500'); + if (placeholder && placeholder.textContent === 'Console output will appear here...') { + placeholder.remove(); + } + } + } else { + container.classList.add('hidden'); + } +} + +function appendConsoleLog(message, timestamp) { + if (!consoleOutputElement) { + consoleOutputElement = document.getElementById('consoleOutputWindow'); + } + + if (!consoleOutputElement) { + return; + } + + // Remove initial placeholder message + const placeholder = consoleOutputElement.querySelector('.text-gray-500'); + if (placeholder && placeholder.textContent === 'Console output will appear here...') { + placeholder.remove(); + } + + // Remove save hint when first log arrives + const saveHint = document.getElementById('consoleSaveHint'); + if (saveHint) { + saveHint.remove(); + } + + // Create log line + const logLine = document.createElement('div'); + logLine.className = 'text-green-400 leading-relaxed'; + + // Format timestamp if provided + let timeStr = ''; + if (timestamp) { + try { + const date = new Date(timestamp); + timeStr = '[' + date.toLocaleTimeString() + '] '; + } catch (e) { + // Ignore timestamp parsing errors + } + } + + // Escape HTML to prevent XSS (but preserve timestamp HTML) + let escapedMessage = message; + if (typeof escapeHtml === 'function') { + escapedMessage = escapeHtml(escapedMessage); + } else { + // Fallback HTML escaping + escapedMessage = escapedMessage + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // Color code different log levels + let logClass = 'text-green-400'; + const msgLower = message.toLowerCase(); + if (msgLower.includes('error') || msgLower.includes('fatal')) { + logClass = 'text-red-400'; + } else if (msgLower.includes('warning') || msgLower.includes('warn')) { + logClass = 'text-yellow-400'; + } else if (msgLower.includes('info') || msgLower.includes('debug')) { + logClass = 'text-blue-400'; + } + + logLine.className = logClass + ' leading-relaxed'; + logLine.innerHTML = timeStr + escapedMessage; + + // Add to console + consoleOutputElement.appendChild(logLine); + + // Limit number of lines + const lines = consoleOutputElement.children; + if (lines.length > maxConsoleLines) { + consoleOutputElement.removeChild(lines[0]); + } + + // Auto-scroll to bottom + consoleOutputElement.scrollTop = consoleOutputElement.scrollHeight; +} + +function clearConsole() { + if (!consoleOutputElement) { + consoleOutputElement = document.getElementById('consoleOutputWindow'); + } + + if (consoleOutputElement) { + consoleOutputElement.textContent = ''; + // Note: wasConsoleEnabledOnLoad remains true, so hint won't show again after clear + } +} + +// Initialize on page load +if (typeof window !== 'undefined') { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + // Check if console output is already enabled on page load + const checkbox = document.getElementById('consoleOutput'); + if (checkbox && checkbox.checked) { + wasConsoleEnabledOnLoad = true; // Mark that it was enabled on load + toggleConsoleOutput(false); // false = not a user click + } + initConsoleOutput(); + }); + } else { + // Check if console output is already enabled on page load + const checkbox = document.getElementById('consoleOutput'); + if (checkbox && checkbox.checked) { + wasConsoleEnabledOnLoad = true; // Mark that it was enabled on load + toggleConsoleOutput(false); // false = not a user click + } + initConsoleOutput(); + } +} diff --git a/pkg/web/static/js/settings.js b/pkg/web/static/js/settings.js index 8c51576..e213071 100644 --- a/pkg/web/static/js/settings.js +++ b/pkg/web/static/js/settings.js @@ -70,6 +70,15 @@ function loadSettings() { } document.getElementById('debugMode').checked = data.debug || false; + const consoleOutputEl = document.getElementById('consoleOutput'); + if (consoleOutputEl) { + consoleOutputEl.checked = data.consoleOutput || false; + // Mark that console was enabled on load (settings were already saved) + if (typeof wasConsoleEnabledOnLoad !== 'undefined') { + wasConsoleEnabledOnLoad = consoleOutputEl.checked; + } + toggleConsoleOutput(false); // false = not a user click, loading from saved settings + } // Set callback URL and add auto-update listener for port changes const callbackURLInput = document.getElementById('callbackURL'); @@ -201,6 +210,7 @@ function saveSettings(event) { language: document.getElementById('languageSelect').value, port: currentPort, debug: document.getElementById('debugMode').checked, + consoleOutput: document.getElementById('consoleOutput') ? document.getElementById('consoleOutput').checked : false, destemail: document.getElementById('destEmail').value.trim(), callbackUrl: callbackUrl, callbackSecret: document.getElementById('callbackSecret').value.trim(), diff --git a/pkg/web/static/js/websocket.js b/pkg/web/static/js/websocket.js index 07cff34..e6290d3 100644 --- a/pkg/web/static/js/websocket.js +++ b/pkg/web/static/js/websocket.js @@ -15,6 +15,7 @@ class WebSocketManager { this.lastBanEventId = null; this.statusCallbacks = []; this.banEventCallbacks = []; + this.consoleLogCallbacks = []; // Connection metrics for tooltip this.connectedAt = null; @@ -58,11 +59,20 @@ class WebSocketManager { this.ws.onmessage = (event) => { try { - const message = JSON.parse(event.data); - this.messageCount++; - this.handleMessage(message); + // WebSocket may send multiple JSON messages separated by newlines + // Split by newlines and parse each message separately + const messages = event.data.split('\n').filter(line => line.trim().length > 0); + for (const messageText of messages) { + try { + const message = JSON.parse(messageText); + this.messageCount++; + this.handleMessage(message); + } catch (parseErr) { + console.error('Error parsing individual WebSocket message:', parseErr, 'Raw:', messageText); + } + } } catch (err) { - console.error('Error parsing WebSocket message:', err); + console.error('Error processing WebSocket message:', err); } }; @@ -112,11 +122,27 @@ class WebSocketManager { case 'heartbeat': this.handleHeartbeat(message); break; + case 'console_log': + this.handleConsoleLog(message); + break; default: console.log('Unknown message type:', message.type); } } + handleConsoleLog(message) { + // Notify all registered console log callbacks + if (this.consoleLogCallbacks) { + this.consoleLogCallbacks.forEach(callback => { + try { + callback(message.message, message.time); + } catch (err) { + console.error('Error in console log callback:', err); + } + }); + } + } + handleBanEvent(eventData) { // Check if we've already processed this event (prevent duplicates) // Only check if event has an ID and we have a lastBanEventId @@ -170,6 +196,13 @@ class WebSocketManager { this.banEventCallbacks.push(callback); } + onConsoleLog(callback) { + if (!this.consoleLogCallbacks) { + this.consoleLogCallbacks = []; + } + this.consoleLogCallbacks.push(callback); + } + disconnect() { if (this.ws) { this.ws.close(); diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 34417c0..842c9ec 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -231,9 +231,33 @@ -
- - +
+
+ + +
+
+ + +
+
+ + +
@@ -1355,6 +1379,7 @@ + diff --git a/pkg/web/websocket.go b/pkg/web/websocket.go index f8ae028..6bda962 100644 --- a/pkg/web/websocket.go +++ b/pkg/web/websocket.go @@ -73,6 +73,26 @@ type Hub struct { mu sync.RWMutex } +// BroadcastConsoleLog broadcasts a console log message to all connected clients +func (h *Hub) BroadcastConsoleLog(message string) { + logMsg := map[string]interface{}{ + "type": "console_log", + "message": message, + "time": time.Now().UTC().Format(time.RFC3339), + } + data, err := json.Marshal(logMsg) + if err != nil { + log.Printf("Error marshaling console log: %v", err) + return + } + + select { + case h.broadcast <- data: + default: + // Channel full, drop message + } +} + // NewHub creates a new WebSocket hub func NewHub() *Hub { return &Hub{