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.

This commit is contained in:
2026-01-14 21:47:17 +01:00
parent e997059e2a
commit 44da16977c
16 changed files with 501 additions and 15 deletions

View File

@@ -82,6 +82,15 @@ func main() {
wsHub := web.NewHub() wsHub := web.NewHub()
go wsHub.Run() 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. // Register all application routes, including the static files and templates.
web.RegisterRoutes(router, wsHub) web.RegisterRoutes(router, wsHub)

View File

@@ -82,6 +82,9 @@ type AppSettings struct {
// Email alert preferences // Email alert preferences
EmailAlertsForBans bool `json:"emailAlertsForBans"` // Enable email alerts for ban events (default: true) EmailAlertsForBans bool `json:"emailAlertsForBans"` // Enable email alerts for ban events (default: true)
EmailAlertsForUnbans bool `json:"emailAlertsForUnbans"` // Enable email alerts for unban events (default: false) 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 { type AdvancedActionsConfig struct {
@@ -425,6 +428,7 @@ func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) {
currentSettings.CallbackSecret = rec.CallbackSecret currentSettings.CallbackSecret = rec.CallbackSecret
currentSettings.EmailAlertsForBans = rec.EmailAlertsForBans currentSettings.EmailAlertsForBans = rec.EmailAlertsForBans
currentSettings.EmailAlertsForUnbans = rec.EmailAlertsForUnbans currentSettings.EmailAlertsForUnbans = rec.EmailAlertsForUnbans
currentSettings.ConsoleOutput = rec.ConsoleOutput
} }
func applyServerRecordsLocked(records []storage.ServerRecord) { func applyServerRecordsLocked(records []storage.ServerRecord) {
@@ -511,6 +515,8 @@ func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
GeoIPProvider: currentSettings.GeoIPProvider, GeoIPProvider: currentSettings.GeoIPProvider,
GeoIPDatabasePath: currentSettings.GeoIPDatabasePath, GeoIPDatabasePath: currentSettings.GeoIPDatabasePath,
MaxLogLines: currentSettings.MaxLogLines, MaxLogLines: currentSettings.MaxLogLines,
// Console output settings
ConsoleOutput: currentSettings.ConsoleOutput,
}, nil }, nil
} }
@@ -1529,9 +1535,31 @@ func UpdateSettings(new AppSettings) (AppSettings, error) {
} }
DebugLog("New settings applied: %v", currentSettings) // Log settings applied 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 { if err := persistAllLocked(); err != nil {
fmt.Println("Error saving settings:", err) fmt.Println("Error saving settings:", err)
return currentSettings, err return currentSettings, err
} }
return currentSettings, nil 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)
}
}

View File

@@ -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_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.port_restart_hint": "⚠️ Port-Änderungen erfordern einen Neustart des Containers, um wirksam zu werden.",
"settings.enable_debug": "Debug-Protokoll aktivieren", "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.alert": "Alarm-Einstellungen",
"settings.callback_url": "Fail2ban Callback-URL", "settings.callback_url": "Fail2ban Callback-URL",
"settings.callback_url_placeholder": "http://127.0.0.1:8080", "settings.callback_url_placeholder": "http://127.0.0.1:8080",

View File

@@ -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_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.port_restart_hint": "⚠️ Port-Änderige erfordere ä Neustart vom Container, zum wirksam z werde.",
"settings.enable_debug": "Debug-Modus aktivierä", "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.alert": "Alarm-Istellige",
"settings.callback_url": "Fail2ban Callback-URL", "settings.callback_url": "Fail2ban Callback-URL",
"settings.callback_url_placeholder": "http://127.0.0.1:8080", "settings.callback_url_placeholder": "http://127.0.0.1:8080",

View File

@@ -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_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.port_restart_hint": "⚠️ Port changes require a container restart to take effect.",
"settings.enable_debug": "Enable Debug Log", "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.alert": "Alert Settings",
"settings.callback_url": "Fail2ban Callback URL", "settings.callback_url": "Fail2ban Callback URL",
"settings.callback_url_placeholder": "http://127.0.0.1:8080", "settings.callback_url_placeholder": "http://127.0.0.1:8080",

View File

@@ -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_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.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_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.alert": "Configuración de alertas",
"settings.callback_url": "URL de retorno de Fail2ban", "settings.callback_url": "URL de retorno de Fail2ban",
"settings.callback_url_placeholder": "http://127.0.0.1:8080", "settings.callback_url_placeholder": "http://127.0.0.1:8080",

View File

@@ -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_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.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_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.alert": "Paramètres d'alerte",
"settings.callback_url": "URL de rappel Fail2ban", "settings.callback_url": "URL de rappel Fail2ban",
"settings.callback_url_placeholder": "http://127.0.0.1:8080", "settings.callback_url_placeholder": "http://127.0.0.1:8080",

View File

@@ -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_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.port_restart_hint": "⚠️ Le modifiche alla porta richiedono un riavvio del contenitore per avere effetto.",
"settings.enable_debug": "Abilita debug", "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.alert": "Impostazioni di allarme",
"settings.callback_url": "URL di callback Fail2ban", "settings.callback_url": "URL di callback Fail2ban",
"settings.callback_url_placeholder": "http://127.0.0.1:8080", "settings.callback_url_placeholder": "http://127.0.0.1:8080",

View File

@@ -60,6 +60,8 @@ type AppSettingsRecord struct {
AlertCountriesJSON string AlertCountriesJSON string
EmailAlertsForBans bool EmailAlertsForBans bool
EmailAlertsForUnbans bool EmailAlertsForUnbans bool
// Console output settings
ConsoleOutput bool
// SMTP settings // SMTP settings
SMTPHost string SMTPHost string
SMTPPort int SMTPPort int
@@ -190,17 +192,17 @@ func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
} }
row := db.QueryRowContext(ctx, ` 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 FROM app_settings
WHERE id = 1`) WHERE id = 1`)
var ( var (
lang, callback, callbackSecret, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail, banaction, banactionAllports, advancedActions, geoipProvider, geoipDatabasePath sql.NullString 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 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) { if errors.Is(err, sql.ErrNoRows) {
return AppSettingsRecord{}, false, nil return AppSettingsRecord{}, false, nil
} }
@@ -243,6 +245,8 @@ WHERE id = 1`)
GeoIPProvider: stringFromNull(geoipProvider), GeoIPProvider: stringFromNull(geoipProvider),
GeoIPDatabasePath: stringFromNull(geoipDatabasePath), GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
MaxLogLines: intFromNull(maxLogLines), MaxLogLines: intFromNull(maxLogLines),
// Console output settings
ConsoleOutput: intToBool(intFromNull(consoleOutput)),
} }
return rec, true, nil return rec, true, nil
@@ -254,9 +258,9 @@ func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error {
} }
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
INSERT INTO app_settings ( 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 ( ) VALUES (
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) ON CONFLICT(id) DO UPDATE SET ) ON CONFLICT(id) DO UPDATE SET
language = excluded.language, language = excluded.language,
port = excluded.port, port = excluded.port,
@@ -285,7 +289,8 @@ INSERT INTO app_settings (
advanced_actions = excluded.advanced_actions, advanced_actions = excluded.advanced_actions,
geoip_provider = excluded.geoip_provider, geoip_provider = excluded.geoip_provider,
geoip_database_path = excluded.geoip_database_path, 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.Language,
rec.Port, rec.Port,
boolToInt(rec.Debug), boolToInt(rec.Debug),
@@ -314,7 +319,7 @@ INSERT INTO app_settings (
rec.GeoIPProvider, rec.GeoIPProvider,
rec.GeoIPDatabasePath, rec.GeoIPDatabasePath,
rec.MaxLogLines, rec.MaxLogLines,
) boolToInt(rec.ConsoleOutput))
return err return err
} }
@@ -861,7 +866,9 @@ CREATE TABLE IF NOT EXISTS app_settings (
advanced_actions TEXT, advanced_actions TEXT,
geoip_provider TEXT, geoip_provider TEXT,
geoip_database_path 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 ( 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 // 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 _ = strings.Contains // Keep strings import for migration example above
return nil return nil

105
pkg/web/console_logger.go Normal file
View File

@@ -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)
}
}

View File

@@ -462,6 +462,11 @@ button.bg-red-500:hover, button.bg-red-600:hover {
color: rgb(107 114 128); color: rgb(107 114 128);
} }
/* Console output text colors */
.text-gray-100 {
color: #f3f4f6;
}
#wsTooltip .border-gray-700 { #wsTooltip .border-gray-700 {
border-color: rgb(55 65 81); border-color: rgb(55 65 81);
} }

View File

@@ -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 = '<span class="text-gray-500">[' + date.toLocaleTimeString() + ']</span> ';
} 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// 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();
}
}

View File

@@ -70,6 +70,15 @@ function loadSettings() {
} }
document.getElementById('debugMode').checked = data.debug || false; 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 // Set callback URL and add auto-update listener for port changes
const callbackURLInput = document.getElementById('callbackURL'); const callbackURLInput = document.getElementById('callbackURL');
@@ -201,6 +210,7 @@ function saveSettings(event) {
language: document.getElementById('languageSelect').value, language: document.getElementById('languageSelect').value,
port: currentPort, port: currentPort,
debug: document.getElementById('debugMode').checked, debug: document.getElementById('debugMode').checked,
consoleOutput: document.getElementById('consoleOutput') ? document.getElementById('consoleOutput').checked : false,
destemail: document.getElementById('destEmail').value.trim(), destemail: document.getElementById('destEmail').value.trim(),
callbackUrl: callbackUrl, callbackUrl: callbackUrl,
callbackSecret: document.getElementById('callbackSecret').value.trim(), callbackSecret: document.getElementById('callbackSecret').value.trim(),

View File

@@ -15,6 +15,7 @@ class WebSocketManager {
this.lastBanEventId = null; this.lastBanEventId = null;
this.statusCallbacks = []; this.statusCallbacks = [];
this.banEventCallbacks = []; this.banEventCallbacks = [];
this.consoleLogCallbacks = [];
// Connection metrics for tooltip // Connection metrics for tooltip
this.connectedAt = null; this.connectedAt = null;
@@ -58,11 +59,20 @@ class WebSocketManager {
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data); // WebSocket may send multiple JSON messages separated by newlines
this.messageCount++; // Split by newlines and parse each message separately
this.handleMessage(message); 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) { } catch (err) {
console.error('Error parsing WebSocket message:', err); console.error('Error processing WebSocket message:', err);
} }
}; };
@@ -112,11 +122,27 @@ class WebSocketManager {
case 'heartbeat': case 'heartbeat':
this.handleHeartbeat(message); this.handleHeartbeat(message);
break; break;
case 'console_log':
this.handleConsoleLog(message);
break;
default: default:
console.log('Unknown message type:', message.type); 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) { handleBanEvent(eventData) {
// Check if we've already processed this event (prevent duplicates) // Check if we've already processed this event (prevent duplicates)
// Only check if event has an ID and we have a lastBanEventId // Only check if event has an ID and we have a lastBanEventId
@@ -170,6 +196,13 @@ class WebSocketManager {
this.banEventCallbacks.push(callback); this.banEventCallbacks.push(callback);
} }
onConsoleLog(callback) {
if (!this.consoleLogCallbacks) {
this.consoleLogCallbacks = [];
}
this.consoleLogCallbacks.push(callback);
}
disconnect() { disconnect() {
if (this.ws) { if (this.ws) {
this.ws.close(); this.ws.close();

View File

@@ -231,9 +231,33 @@
</div> </div>
<!-- Debug Log Output --> <!-- Debug Log Output -->
<div class="flex items-center border border-gray-200 rounded-lg p-2 overflow-x-auto bg-gray-50"> <div class="flex items-center gap-4 border border-gray-200 rounded-lg p-2 overflow-x-auto bg-gray-50">
<input type="checkbox" id="debugMode" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out"> <div class="flex items-center">
<label for="debugMode" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_debug">Enable Debug Log</label> <input type="checkbox" id="debugMode" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out">
<label for="debugMode" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_debug">Enable Debug Log</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="consoleOutput" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" onchange="toggleConsoleOutput(true)">
<label for="consoleOutput" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_console">Enable Console Output</label>
</div>
</div>
<!-- Console Output Window -->
<div id="consoleOutputContainer" class="hidden mt-4 border border-gray-700 rounded-lg bg-gray-900 shadow-lg overflow-hidden">
<div class="flex items-center justify-between bg-gray-800 px-4 py-2 border-b border-gray-700">
<div class="flex items-center gap-2">
<div class="flex gap-1.5">
<div class="w-3 h-3 rounded-full bg-red-500"></div>
<div class="w-3 h-3 rounded-full bg-yellow-500"></div>
<div class="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<h4 class="text-sm font-medium text-gray-100 ml-2" data-i18n="settings.console.title">Console Output</h4>
</div>
<button type="button" onclick="clearConsole()" class="text-xs text-gray-100 hover:text-white px-2 py-1 rounded hover:bg-gray-700 transition-colors" data-i18n="settings.console.clear">Clear</button>
</div>
<div id="consoleOutputWindow" class="overflow-y-auto p-4 font-mono text-sm text-green-400 bg-gray-900" style="max-height: 430px; min-height: 200px;">
<div class="text-gray-500">Console output will appear here...</div>
</div>
</div> </div>
</div> </div>
@@ -1355,6 +1379,7 @@
<script src="/static/js/dashboard.js?v={{.version}}"></script> <script src="/static/js/dashboard.js?v={{.version}}"></script>
<script src="/static/js/servers.js?v={{.version}}"></script> <script src="/static/js/servers.js?v={{.version}}"></script>
<script src="/static/js/jails.js?v={{.version}}"></script> <script src="/static/js/jails.js?v={{.version}}"></script>
<script src="/static/js/console.js?v={{.version}}"></script>
<script src="/static/js/settings.js?v={{.version}}"></script> <script src="/static/js/settings.js?v={{.version}}"></script>
<script src="/static/js/filters.js?v={{.version}}"></script> <script src="/static/js/filters.js?v={{.version}}"></script>
<script src="/static/js/websocket.js?v={{.version}}"></script> <script src="/static/js/websocket.js?v={{.version}}"></script>

View File

@@ -73,6 +73,26 @@ type Hub struct {
mu sync.RWMutex 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 // NewHub creates a new WebSocket hub
func NewHub() *Hub { func NewHub() *Hub {
return &Hub{ return &Hub{