Reorganize the whole javascript part as seperate files, for better maintainabillity

This commit is contained in:
2025-12-05 14:30:28 +01:00
parent fb4796de59
commit 2ce9c0095d
24 changed files with 3535 additions and 3112 deletions

View File

@@ -113,6 +113,7 @@ func parseJailConfigFile(path string) ([]JailInfo, error) {
// UpdateJailEnabledStates updates the enabled state for each jail based on the provided updates map.
// Updates only the corresponding file in /etc/fail2ban/jail.d/ for each jail.
func UpdateJailEnabledStates(updates map[string]bool) error {
config.DebugLog("UpdateJailEnabledStates called with %d updates: %+v", len(updates), updates)
jailDPath := "/etc/fail2ban/jail.d"
// Ensure jail.d directory exists
@@ -122,7 +123,9 @@ func UpdateJailEnabledStates(updates map[string]bool) error {
// Update each jail in its own file
for jailName, enabled := range updates {
config.DebugLog("Processing jail: %s, enabled: %t", jailName, enabled)
jailFilePath := filepath.Join(jailDPath, jailName+".conf")
config.DebugLog("Jail file path: %s", jailFilePath)
// Read existing file if it exists
content, err := os.ReadFile(jailFilePath)
@@ -185,9 +188,13 @@ func UpdateJailEnabledStates(updates map[string]bool) error {
// Write updated content
newContent := strings.Join(outputLines, "\n")
if !strings.HasSuffix(newContent, "\n") {
newContent += "\n"
}
if err := os.WriteFile(jailFilePath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err)
}
config.DebugLog("Updated jail %s: enabled = %t (file: %s)", jailName, enabled, jailFilePath)
}
return nil
}

View File

@@ -236,6 +236,7 @@
"servers.actions.test": "Verbindung testen",
"servers.actions.test_success": "Verbindung erfolgreich",
"servers.actions.test_failure": "Verbindung fehlgeschlagen",
"servers.actions.restart": "Fail2ban neu starten",
"servers.actions.delete": "Löschen",
"servers.actions.delete_confirm": "Diesen Servereintrag löschen?",
"servers.form.select_key": "Privaten Schlüssel auswählen",

View File

@@ -236,6 +236,7 @@
"servers.actions.test": "Verbindig teste",
"servers.actions.test_success": "Verbindig erfolgriich",
"servers.actions.test_failure": "Verbindig nöd möglich",
"servers.actions.restart": "Fail2ban neu starte",
"servers.actions.delete": "Lösche",
"servers.actions.delete_confirm": "Dä Servereintrag lösche?",
"servers.form.select_key": "Priväte Schlissel ufwähle",

View File

@@ -236,6 +236,7 @@
"servers.actions.test": "Test connection",
"servers.actions.test_success": "Connection successful",
"servers.actions.test_failure": "Connection failed",
"servers.actions.restart": "Restart Fail2ban",
"servers.actions.delete": "Delete",
"servers.actions.delete_confirm": "Delete this server entry?",
"servers.form.select_key": "Select Private Key",

View File

@@ -236,6 +236,7 @@
"servers.actions.test": "Probar conexión",
"servers.actions.test_success": "Conexión exitosa",
"servers.actions.test_failure": "Conexión fallida",
"servers.actions.restart": "Reiniciar Fail2ban",
"servers.actions.delete": "Eliminar",
"servers.actions.delete_confirm": "¿Eliminar este servidor?",
"servers.form.select_key": "Seleccionar clave privada",

View File

@@ -236,6 +236,7 @@
"servers.actions.test": "Tester la connexion",
"servers.actions.test_success": "Connexion réussie",
"servers.actions.test_failure": "Échec de la connexion",
"servers.actions.restart": "Redémarrer Fail2ban",
"servers.actions.delete": "Supprimer",
"servers.actions.delete_confirm": "Supprimer ce serveur ?",
"servers.form.select_key": "Sélectionner la clé privée",

View File

@@ -236,6 +236,7 @@
"servers.actions.test": "Verifica connessione",
"servers.actions.test_success": "Connessione riuscita",
"servers.actions.test_failure": "Connessione fallita",
"servers.actions.restart": "Riavvia Fail2ban",
"servers.actions.delete": "Elimina",
"servers.actions.delete_confirm": "Eliminare questo server?",
"servers.form.select_key": "Seleziona chiave privata",

View File

@@ -1011,19 +1011,29 @@ func UpdateJailManagementHandler(c *gin.Context) {
config.DebugLog("UpdateJailManagementHandler called (handlers.go)") // entry point
conn, err := resolveConnector(c)
if err != nil {
config.DebugLog("Error resolving connector: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var updates map[string]bool
if err := c.ShouldBindJSON(&updates); err != nil {
config.DebugLog("Error parsing JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
config.DebugLog("Received jail updates: %+v", updates)
if len(updates) == 0 {
config.DebugLog("Warning: No jail updates provided")
c.JSON(http.StatusBadRequest, gin.H{"error": "No jail updates provided"})
return
}
// Update jail configuration file(s) with the new enabled states.
if err := conn.UpdateJailEnabledStates(c.Request.Context(), updates); err != nil {
config.DebugLog("Error updating jail enabled states: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()})
return
}
config.DebugLog("Successfully updated jail enabled states")
// Reload fail2ban to apply the changes (reload is sufficient for jail enable/disable)
if err := conn.Reload(c.Request.Context()); err != nil {
config.DebugLog("Warning: failed to reload fail2ban after updating jail settings: %v", err)
@@ -1135,9 +1145,12 @@ func UpdateSettingsHandler(c *gin.Context) {
errors = append(errors, errorMsg)
} else {
config.DebugLog("Successfully updated DEFAULT settings on %s", server.Name)
// Mark server as needing restart
if err := config.MarkRestartNeeded(server.ID); err != nil {
config.DebugLog("Warning: failed to mark restart needed for %s: %v", server.Name, err)
// Reload fail2ban to apply the changes
if err := conn.Reload(c.Request.Context()); err != nil {
config.DebugLog("Warning: failed to reload fail2ban on %s after updating DEFAULT settings: %v", server.Name, err)
errors = append(errors, fmt.Sprintf("Settings updated on %s, but reload failed: %v", server.Name, err))
} else {
config.DebugLog("Successfully reloaded fail2ban on %s", server.Name)
}
}
}
@@ -1146,11 +1159,17 @@ func UpdateSettingsHandler(c *gin.Context) {
// Don't fail the request, but include warnings in response
c.JSON(http.StatusOK, gin.H{
"message": "Settings updated",
"restartNeeded": newSettings.RestartNeeded,
"restartNeeded": false, // We reloaded, so no restart needed
"warnings": errors,
})
return
}
// Settings were updated and reloaded successfully, no restart needed
c.JSON(http.StatusOK, gin.H{
"message": "Settings updated and fail2ban reloaded",
"restartNeeded": false, // We reloaded, so no restart needed
})
return
}
c.JSON(http.StatusOK, gin.H{
@@ -1245,13 +1264,30 @@ func ApplyFail2banSettings(jailLocalPath string) error {
// RestartFail2banHandler reloads the Fail2ban service
func RestartFail2banHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("ApplyFail2banSettings called (handlers.go)") // entry point
config.DebugLog("RestartFail2banHandler called (handlers.go)") // entry point
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
// Check if serverId is provided in query parameter
serverID := c.Query("serverId")
var conn fail2ban.Connector
var err error
if serverID != "" {
// Use specific server
manager := fail2ban.GetManager()
conn, err = manager.Connector(serverID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Server not found: " + err.Error()})
return
}
} else {
// Use default connector from context
conn, err = resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
server := conn.Server()
// Attempt to restart the fail2ban service.

19
pkg/web/static/js/api.js Normal file
View File

@@ -0,0 +1,19 @@
// API helper functions for Fail2ban UI
// Add server parameter to URL
function withServerParam(url) {
if (!currentServerId) {
return url;
}
return url + (url.indexOf('?') === -1 ? '?' : '&') + 'serverId=' + encodeURIComponent(currentServerId);
}
// Get server headers for API requests
function serverHeaders(headers) {
headers = headers || {};
if (currentServerId) {
headers['X-F2B-Server'] = currentServerId;
}
return headers;
}

201
pkg/web/static/js/core.js Normal file
View File

@@ -0,0 +1,201 @@
// Core utility functions for Fail2ban UI
// Toggle the loading overlay (with !important)
function showLoading(show) {
var overlay = document.getElementById('loading-overlay');
if (overlay) {
if (show) {
overlay.style.setProperty('display', 'flex', 'important');
setTimeout(() => overlay.classList.add('show'), 10);
} else {
overlay.classList.remove('show'); // Start fade-out
setTimeout(() => overlay.style.setProperty('display', 'none', 'important'), 400);
}
}
}
// Show toast notification
function showToast(message, type) {
var container = document.getElementById('toast-container');
if (!container || !message) return;
var toast = document.createElement('div');
var variant = type || 'info';
toast.className = 'toast toast-' + variant;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(function() {
toast.classList.add('show');
});
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() {
toast.remove();
}, 300);
}, 5000);
}
// Escape HTML to prevent XSS
function escapeHtml(value) {
if (value === undefined || value === null) return '';
return String(value).replace(/[&<>"']/g, function(match) {
switch (match) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
default: return '&#39;';
}
});
}
// Format number with locale
function formatNumber(value) {
var num = Number(value);
if (!isFinite(num)) {
return '0';
}
try {
return num.toLocaleString();
} catch (e) {
return String(num);
}
}
// Format date/time (custom format for dashboard)
function formatDateTime(value) {
if (!value) return '';
var date = new Date(value);
if (isNaN(date.getTime())) {
return value;
}
// Format as "2025.11.12, 21:21:52"
var year = date.getFullYear();
var month = String(date.getMonth() + 1).padStart(2, '0');
var day = String(date.getDate()).padStart(2, '0');
var hours = String(date.getHours()).padStart(2, '0');
var minutes = String(date.getMinutes()).padStart(2, '0');
var seconds = String(date.getSeconds()).padStart(2, '0');
return year + '.' + month + '.' + day + ', ' + hours + ':' + minutes + ':' + seconds;
}
// Fetch and display own external IP for webUI
function displayExternalIP() {
const target = document.getElementById('external-ip');
if (!target) return;
const providers = [
{ url: 'https://api.ipify.org?format=json', extract: data => data.ip },
{ url: 'https://ipapi.co/json/', extract: data => data && (data.ip || data.ip_address) },
{ url: 'https://ipv4.jsonip.com/', extract: data => data.ip }
];
const tryProvider = (index) => {
if (index >= providers.length) {
target.textContent = 'Unavailable';
return;
}
const provider = providers[index];
fetch(provider.url, { headers: { 'Accept': 'application/json' } })
.then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(data => {
const ip = provider.extract(data);
if (ip) {
target.textContent = ip;
} else {
throw new Error('Missing IP');
}
})
.catch(() => {
tryProvider(index + 1);
});
};
tryProvider(0);
}
// Function to initialize tooltips
function initializeTooltips() {
const tooltips = document.querySelectorAll('[data-tooltip]');
tooltips.forEach(el => {
el.addEventListener('mouseenter', () => {
const tooltip = document.createElement('div');
tooltip.className = 'absolute z-10 bg-gray-800 text-white text-xs rounded py-1 px-2 whitespace-nowrap';
tooltip.textContent = el.getAttribute('data-tooltip');
tooltip.style.top = (el.offsetTop - 30) + 'px';
tooltip.style.left = (el.offsetLeft + (el.offsetWidth / 2) - (tooltip.offsetWidth / 2)) + 'px';
tooltip.id = 'tooltip-' + Date.now();
document.body.appendChild(tooltip);
el.setAttribute('data-tooltip-id', tooltip.id);
});
el.addEventListener('mouseleave', () => {
const tooltipId = el.getAttribute('data-tooltip-id');
if (tooltipId) {
const tooltip = document.getElementById(tooltipId);
if (tooltip) tooltip.remove();
el.removeAttribute('data-tooltip-id');
}
});
});
}
// Function to initialize the IP search
function initializeSearch() {
const ipSearch = document.getElementById("ipSearch");
if (ipSearch) {
ipSearch.addEventListener("keypress", function(event) {
const char = String.fromCharCode(event.which);
if (!/[0-9.]/.test(char)) {
event.preventDefault();
}
});
}
}
// Update restart banner visibility
function updateRestartBanner() {
var banner = document.getElementById('restartBanner');
if (!banner) return;
if (currentServer && currentServer.restartNeeded) {
banner.style.display = 'block';
} else {
banner.style.display = 'none';
}
}
// Load dynamically the other pages when navigating in nav
function showSection(sectionId) {
// hide all sections
document.getElementById('dashboardSection').classList.add('hidden');
document.getElementById('filterSection').classList.add('hidden');
document.getElementById('settingsSection').classList.add('hidden');
// show the requested section
document.getElementById(sectionId).classList.remove('hidden');
// If it's filterSection, load filters
if (sectionId === 'filterSection') {
if (typeof showFilterSection === 'function') {
showFilterSection();
}
}
// If it's settingsSection, load settings
if (sectionId === 'settingsSection') {
if (typeof loadSettings === 'function') {
loadSettings();
}
}
// Close navbar on mobile when clicking a menu item
document.getElementById('mobileMenu').classList.add('hidden');
}
// Toggle mobile menu
function toggleMobileMenu() {
const menu = document.getElementById('mobileMenu');
menu.classList.toggle('hidden');
}

View File

@@ -0,0 +1,715 @@
// Dashboard rendering and data fetching functions for Fail2ban UI
"use strict";
function refreshData(options) {
options = options || {};
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
var summaryPromise;
if (!serversCache.length || !enabledServers.length || !currentServerId) {
latestSummary = null;
latestSummaryError = null;
summaryPromise = Promise.resolve();
} else {
summaryPromise = fetchSummaryData();
}
if (!options.silent) {
showLoading(true);
}
return Promise.all([
summaryPromise,
fetchBanStatisticsData(),
fetchBanEventsData(),
fetchBanInsightsData()
])
.then(function() {
renderDashboard();
})
.catch(function(err) {
console.error('Error refreshing data:', err);
latestSummaryError = err ? err.toString() : 'Unknown error';
renderDashboard();
})
.finally(function() {
if (!options.silent) {
showLoading(false);
}
});
}
function fetchSummaryData() {
return fetch(withServerParam('/api/summary'))
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && !data.error) {
latestSummary = data;
latestSummaryError = null;
} else {
latestSummary = null;
latestSummaryError = data && data.error ? data.error : t('dashboard.errors.summary_failed', 'Failed to load summary from server.');
}
})
.catch(function(err) {
latestSummary = null;
latestSummaryError = err ? err.toString() : 'Unknown error';
});
}
function fetchBanStatisticsData() {
return fetch('/api/events/bans/stats')
.then(function(res) { return res.json(); })
.then(function(data) {
latestBanStats = data && data.counts ? data.counts : {};
})
.catch(function(err) {
console.error('Error fetching ban statistics:', err);
latestBanStats = latestBanStats || {};
});
}
function fetchBanEventsData() {
return fetch('/api/events/bans?limit=200')
.then(function(res) { return res.json(); })
.then(function(data) {
latestBanEvents = data && data.events ? data.events : [];
})
.catch(function(err) {
console.error('Error fetching ban events:', err);
latestBanEvents = latestBanEvents || [];
});
}
function fetchBanInsightsData() {
var sevenDaysAgo = new Date(Date.now() - (7 * 24 * 60 * 60 * 1000)).toISOString();
var sinceQuery = '?since=' + encodeURIComponent(sevenDaysAgo);
var globalPromise = fetch('/api/events/bans/insights' + sinceQuery)
.then(function(res) { return res.json(); })
.then(function(data) {
latestBanInsights = normalizeInsights(data);
})
.catch(function(err) {
console.error('Error fetching ban insights:', err);
if (!latestBanInsights) {
latestBanInsights = normalizeInsights(null);
}
});
var serverPromise;
if (currentServerId) {
serverPromise = fetch(withServerParam('/api/events/bans/insights' + sinceQuery))
.then(function(res) { return res.json(); })
.then(function(data) {
latestServerInsights = normalizeInsights(data);
})
.catch(function(err) {
console.error('Error fetching server-specific ban insights:', err);
latestServerInsights = null;
});
} else {
latestServerInsights = null;
serverPromise = Promise.resolve();
}
return Promise.all([globalPromise, serverPromise]);
}
function totalStoredBans() {
if (latestBanInsights && latestBanInsights.totals && typeof latestBanInsights.totals.overall === 'number') {
return latestBanInsights.totals.overall;
}
if (!latestBanStats) return 0;
return Object.keys(latestBanStats).reduce(function(sum, key) {
return sum + (latestBanStats[key] || 0);
}, 0);
}
function totalBansToday() {
if (latestBanInsights && latestBanInsights.totals && typeof latestBanInsights.totals.today === 'number') {
return latestBanInsights.totals.today;
}
return 0;
}
function totalBansWeek() {
if (latestBanInsights && latestBanInsights.totals && typeof latestBanInsights.totals.week === 'number') {
return latestBanInsights.totals.week;
}
return 0;
}
function recurringIPsLastWeekCount() {
var source = latestServerInsights || latestBanInsights;
if (!source || !Array.isArray(source.recurring)) {
return 0;
}
return source.recurring.length;
}
function getBanEventCountries() {
var countries = {};
latestBanEvents.forEach(function(event) {
var country = (event.country || '').trim();
var key = country.toLowerCase();
if (!countries[key]) {
countries[key] = country;
}
});
var keys = Object.keys(countries);
keys.sort();
return keys.map(function(key) {
return countries[key];
});
}
function getFilteredBanEvents() {
var text = (banEventsFilterText || '').toLowerCase();
var countryFilter = (banEventsFilterCountry || '').toLowerCase();
return latestBanEvents.filter(function(event) {
var matchesCountry = !countryFilter || countryFilter === 'all';
if (!matchesCountry) {
var eventCountryValue = (event.country || '').toLowerCase();
if (!eventCountryValue) {
eventCountryValue = '__unknown__';
}
matchesCountry = eventCountryValue === countryFilter;
}
if (!text) {
return matchesCountry;
}
var haystack = [
event.ip,
event.jail,
event.serverName,
event.hostname,
event.country
].map(function(value) {
return (value || '').toLowerCase();
});
var matchesText = haystack.some(function(value) {
return value.indexOf(text) !== -1;
});
return matchesCountry && matchesText;
});
}
function scheduleLogOverviewRender() {
if (banEventsFilterDebounce) {
clearTimeout(banEventsFilterDebounce);
}
banEventsFilterDebounce = setTimeout(function() {
renderLogOverviewSection();
banEventsFilterDebounce = null;
}, 100);
}
function updateBanEventsSearch(value) {
banEventsFilterText = value || '';
scheduleLogOverviewRender();
}
function updateBanEventsCountry(value) {
banEventsFilterCountry = value || 'all';
scheduleLogOverviewRender();
}
function getRecurringIPMap() {
var map = {};
if (latestBanInsights && Array.isArray(latestBanInsights.recurring)) {
latestBanInsights.recurring.forEach(function(stat) {
if (stat && stat.ip) {
map[stat.ip] = stat;
}
});
}
return map;
}
function renderBannedIPs(jailName, ips) {
if (!ips || ips.length === 0) {
return '<em class="text-gray-500" data-i18n="dashboard.no_banned_ips">No banned IPs</em>';
}
var listId = slugifyId(jailName || 'jail', 'banned-list');
var hiddenId = listId + '-hidden';
var toggleId = listId + '-toggle';
var maxVisible = 5;
var visible = ips.slice(0, maxVisible);
var hidden = ips.slice(maxVisible);
var content = '<div class="space-y-2">';
function bannedIpRow(ip) {
var safeIp = escapeHtml(ip);
var encodedIp = encodeURIComponent(ip);
return ''
+ '<div class="flex items-center justify-between banned-ip-item" data-ip="' + safeIp + '">'
+ ' <span class="text-sm" data-ip-value="' + encodedIp + '">' + safeIp + '</span>'
+ ' <button class="bg-yellow-500 text-white px-3 py-1 rounded text-sm hover:bg-yellow-600 transition-colors"'
+ ' onclick="unbanIP(\'' + escapeHtml(jailName) + '\', \'' + escapeHtml(ip) + '\')">'
+ ' <span data-i18n="dashboard.unban">Unban</span>'
+ ' </button>'
+ '</div>';
}
visible.forEach(function(ip) {
content += bannedIpRow(ip);
});
if (hidden.length) {
content += '<div class="space-y-2 mt-2 hidden banned-ip-hidden" id="' + hiddenId + '" data-initially-hidden="true">';
hidden.forEach(function(ip) {
content += bannedIpRow(ip);
});
content += '</div>';
var moreLabel = t('dashboard.banned.show_more', 'Show more') + ' +' + hidden.length;
var lessLabel = t('dashboard.banned.show_less', 'Hide extra');
content += ''
+ '<button type="button" class="text-xs font-semibold text-blue-600 hover:text-blue-800 banned-ip-toggle"'
+ ' id="' + toggleId + '"'
+ ' data-target="' + hiddenId + '"'
+ ' data-more-label="' + escapeHtml(moreLabel) + '"'
+ ' data-less-label="' + escapeHtml(lessLabel) + '"'
+ ' data-expanded="false"'
+ ' onclick="toggleBannedList(\'' + hiddenId + '\', \'' + toggleId + '\')">'
+ escapeHtml(moreLabel)
+ '</button>';
}
content += '</div>';
return content;
}
function filterIPs() {
const input = document.getElementById("ipSearch");
if (!input) {
return;
}
const query = input.value.trim();
const rows = document.querySelectorAll("#jailsTable .jail-row");
rows.forEach(row => {
const hiddenSections = row.querySelectorAll(".banned-ip-hidden");
const toggleButtons = row.querySelectorAll(".banned-ip-toggle");
if (query === "") {
hiddenSections.forEach(section => {
if (section.getAttribute("data-initially-hidden") === "true") {
section.classList.add("hidden");
}
});
toggleButtons.forEach(button => {
const moreLabel = button.getAttribute("data-more-label");
if (moreLabel) {
button.textContent = moreLabel;
}
button.setAttribute("data-expanded", "false");
});
} else {
hiddenSections.forEach(section => section.classList.remove("hidden"));
toggleButtons.forEach(button => {
const lessLabel = button.getAttribute("data-less-label");
if (lessLabel) {
button.textContent = lessLabel;
}
button.setAttribute("data-expanded", "true");
});
}
const ipItems = row.querySelectorAll(".banned-ip-item");
let rowHasMatch = false;
ipItems.forEach(item => {
const span = item.querySelector("span.text-sm");
if (!span) return;
const storedValue = span.getAttribute("data-ip-value");
const originalIP = storedValue ? decodeURIComponent(storedValue) : span.textContent.trim();
if (query === "") {
item.style.display = "";
span.textContent = originalIP;
rowHasMatch = true;
} else if (originalIP.indexOf(query) !== -1) {
item.style.display = "";
span.innerHTML = highlightQueryMatch(originalIP, query);
rowHasMatch = true;
} else {
item.style.display = "none";
}
});
row.style.display = rowHasMatch ? "" : "none";
});
}
function toggleBannedList(hiddenId, buttonId) {
var hidden = document.getElementById(hiddenId);
var button = document.getElementById(buttonId);
if (!hidden || !button) {
return;
}
var isHidden = hidden.classList.contains("hidden");
if (isHidden) {
hidden.classList.remove("hidden");
button.textContent = button.getAttribute("data-less-label") || button.textContent;
button.setAttribute("data-expanded", "true");
} else {
hidden.classList.add("hidden");
button.textContent = button.getAttribute("data-more-label") || button.textContent;
button.setAttribute("data-expanded", "false");
}
}
function unbanIP(jail, ip) {
const confirmMsg = isLOTRModeActive
? 'Restore ' + ip + ' to the realm from ' + jail + '?'
: 'Unban IP ' + ip + ' from jail ' + jail + '?';
if (!confirm(confirmMsg)) {
return;
}
showLoading(true);
var url = '/api/jails/' + encodeURIComponent(jail) + '/unban/' + encodeURIComponent(ip);
fetch(withServerParam(url), {
method: 'POST',
headers: serverHeaders()
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast("Error unbanning IP: " + data.error, 'error');
} else {
showToast(data.message || "IP unbanned successfully", 'success');
}
return refreshData({ silent: true });
})
.catch(function(err) {
showToast("Error: " + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function renderDashboard() {
var container = document.getElementById('dashboard');
if (!container) return;
var focusState = captureFocusState(container);
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
if (!serversCache.length) {
container.innerHTML = ''
+ '<div class="bg-yellow-100 border-l-4 border-yellow-400 text-yellow-700 p-4 rounded mb-4" role="alert">'
+ ' <p class="font-semibold" data-i18n="dashboard.no_servers_title">No Fail2ban servers configured</p>'
+ ' <p class="text-sm mt-1" data-i18n="dashboard.no_servers_body">Add a server to start monitoring and controlling Fail2ban instances.</p>'
+ '</div>';
if (typeof updateTranslations === 'function') updateTranslations();
restoreFocusState(focusState);
return;
}
if (!enabledServers.length) {
container.innerHTML = ''
+ '<div class="bg-yellow-100 border-l-4 border-yellow-400 text-yellow-700 p-4 rounded mb-4" role="alert">'
+ ' <p class="font-semibold" data-i18n="dashboard.no_enabled_servers_title">No active connectors</p>'
+ ' <p class="text-sm mt-1" data-i18n="dashboard.no_enabled_servers_body">Enable the local connector or register a remote Fail2ban server to see live data.</p>'
+ '</div>';
if (typeof updateTranslations === 'function') updateTranslations();
restoreFocusState(focusState);
return;
}
var summary = latestSummary;
var html = '';
if (latestSummaryError) {
html += ''
+ '<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4">'
+ escapeHtml(latestSummaryError)
+ '</div>';
}
if (!summary) {
html += ''
+ '<div class="bg-white rounded-lg shadow p-6 mb-6">'
+ ' <p class="text-gray-500" data-i18n="dashboard.loading_summary">Loading summary data…</p>'
+ '</div>';
} else {
var totalBanned = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.totalBanned || 0); }, 0) : 0;
var newLastHour = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.newInLastHour || 0); }, 0) : 0;
var recurringWeekCount = recurringIPsLastWeekCount();
html += ''
+ '<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">'
+ ' <div class="bg-white rounded-lg shadow p-4">'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.cards.active_jails">Active Jails</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + (summary.jails ? summary.jails.length : 0) + '</p>'
+ ' </div>'
+ ' <div class="bg-white rounded-lg shadow p-4">'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.cards.total_banned">Total Banned IPs</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + totalBanned + '</p>'
+ ' </div>'
+ ' <div class="bg-white rounded-lg shadow p-4">'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.cards.new_last_hour">New Last Hour</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + newLastHour + '</p>'
+ ' </div>'
+ ' <div class="bg-white rounded-lg shadow p-4">'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.cards.recurring_week">Recurring IPs (7 days)</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + recurringWeekCount + '</p>'
+ ' <p class="text-xs text-gray-500 mt-1" data-i18n="dashboard.cards.recurring_hint">Keep an eye on repeated offenders across all servers.</p>'
+ ' </div>'
+ '</div>';
html += ''
+ '<div class="bg-white rounded-lg shadow p-6 mb-6">'
+ ' <div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">'
+ ' <div>'
+ ' <h3 class="text-lg font-medium text-gray-900 mb-2" data-i18n="dashboard.overview">Overview active Jails and Blocks</h3>'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.overview_hint">Use the search to filter banned IPs and click a jail to edit its configuration.</p>'
+ ' <p class="text-sm text-gray-500 mt-1" data-i18n="dashboard.overview_detail">Collapse or expand long lists to quickly focus on impacted services.</p>'
+ ' </div>'
+ ' <div>'
+ ' <label for="ipSearch" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="dashboard.search_label">Search Banned IPs</label>'
+ ' <input type="text" id="ipSearch" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="dashboard.search_placeholder" placeholder="Enter IP address to search" onkeyup="filterIPs()" pattern="[0-9.]*">'
+ ' </div>'
+ ' </div>';
if (!summary.jails || summary.jails.length === 0) {
html += '<p class="text-gray-500 mt-4" data-i18n="dashboard.no_jails">No jails found.</p>';
} else {
html += ''
+ '<div class="overflow-x-auto mt-4">'
+ ' <table class="min-w-full divide-y divide-gray-200 text-sm sm:text-base" id="jailsTable">'
+ ' <thead class="bg-gray-50">'
+ ' <tr>'
+ ' <th class="px-2 py-1 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="dashboard.table.jail">Jail</th>'
+ ' <th class="hidden sm:table-cell px-2 py-1 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="dashboard.table.total_banned">Total Banned</th>'
+ ' <th class="hidden sm:table-cell px-2 py-1 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="dashboard.table.new_last_hour">New Last Hour</th>'
+ ' <th class="px-2 py-1 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="dashboard.table.banned_ips">Banned IPs</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody class="bg-white divide-y divide-gray-200">';
summary.jails.forEach(function(jail) {
var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs || []);
html += ''
+ '<tr class="jail-row hover:bg-gray-50">'
+ ' <td class="px-2 py-1 sm:px-6 sm:py-4 whitespace-normal break-words">'
+ ' <a href="#" onclick="openJailConfigModal(\'' + escapeHtml(jail.jailName) + '\')" class="text-blue-600 hover:text-blue-800">'
+ escapeHtml(jail.jailName)
+ ' </a>'
+ ' </td>'
+ ' <td class="hidden sm:table-cell px-2 py-1 sm:px-6 sm:py-4 whitespace-normal break-words">' + (jail.totalBanned || 0) + '</td>'
+ ' <td class="hidden sm:table-cell px-2 py-1 sm:px-6 sm:py-4 whitespace-normal break-words">' + (jail.newInLastHour || 0) + '</td>'
+ ' <td class="px-2 py-1 sm:px-6 sm:py-4 whitespace-normal break-words">' + bannedHTML + '</td>'
+ '</tr>';
});
html += ' </tbody></table>';
html += '</div>';
}
html += '</div>'; // close overview card
}
html += '<div id="logOverview">' + renderLogOverviewContent() + '</div>';
container.innerHTML = html;
restoreFocusState(focusState);
const extIpEl = document.getElementById('external-ip');
if (extIpEl) {
extIpEl.addEventListener('click', function() {
const ip = extIpEl.textContent.trim();
const searchInput = document.getElementById('ipSearch');
if (searchInput) {
searchInput.value = ip;
filterIPs();
searchInput.focus();
searchInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
filterIPs();
initializeSearch();
if (typeof updateTranslations === 'function') {
updateTranslations();
}
// Update LOTR terminology if active
if (isLOTRModeActive) {
updateDashboardLOTRTerminology(true);
}
}
function renderLogOverviewSection() {
var target = document.getElementById('logOverview');
if (!target) return;
var focusState = captureFocusState(target);
target.innerHTML = renderLogOverviewContent();
restoreFocusState(focusState);
if (typeof updateTranslations === 'function') {
updateTranslations();
}
}
function renderLogOverviewContent() {
var html = ''
+ '<div class="bg-white rounded-lg shadow p-6 mb-6">'
+ ' <div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between mb-4">'
+ ' <div>'
+ ' <h3 class="text-lg font-medium text-gray-900" data-i18n="logs.overview.title">Internal Log Overview</h3>'
+ ' <p class="text-sm text-gray-500" data-i18n="logs.overview.subtitle">Events stored by Fail2ban-UI across all connectors.</p>'
+ ' </div>'
+ ' <button class="text-sm text-blue-600 hover:text-blue-800" onclick="refreshData()" data-i18n="logs.overview.refresh">Refresh data</button>'
+ ' </div>';
var statsKeys = Object.keys(latestBanStats || {});
statsKeys.sort(function(a, b) {
return (latestBanStats[b] || 0) - (latestBanStats[a] || 0);
});
var totalStored = totalStoredBans();
var todayCount = totalBansToday();
var weekCount = totalBansWeek();
if (statsKeys.length === 0 && totalStored === 0) {
html += '<p class="text-gray-500" data-i18n="logs.overview.empty">No ban events recorded yet.</p>';
} else {
html += ''
+ '<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">'
+ ' <div class="border border-gray-200 rounded-lg p-4 flex flex-col gap-4 bg-gray-50">'
+ ' <div class="flex items-start justify-between gap-4">'
+ ' <div>'
+ ' <p class="text-sm text-gray-500" data-i18n="logs.overview.total_events">Total stored events</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + totalStored + '</p>'
+ ' </div>'
+ ' <button type="button" class="inline-flex items-center px-3 py-1 text-sm rounded border border-blue-200 text-blue-600 hover:bg-blue-50" onclick="openBanInsightsModal()" data-i18n="logs.overview.open_insights">Open insights</button>'
+ ' </div>'
+ ' <div class="grid grid-cols-2 gap-4 text-sm">'
+ ' <div>'
+ ' <p class="text-gray-500" data-i18n="logs.overview.total_today">Today</p>'
+ ' <p class="text-lg font-semibold text-gray-900">' + todayCount + '</p>'
+ ' </div>'
+ ' <div>'
+ ' <p class="text-gray-500" data-i18n="logs.overview.total_week">Last 7 days</p>'
+ ' <p class="text-lg font-semibold text-gray-900">' + weekCount + '</p>'
+ ' </div>'
+ ' </div>'
+ ' </div>'
+ ' <div class="border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50">'
+ ' <p class="text-sm text-gray-500 mb-2" data-i18n="logs.overview.per_server">Events per server</p>'
+ ' <table class="min-w-full text-sm">'
+ ' <thead>'
+ ' <tr class="text-left text-xs text-gray-500 uppercase tracking-wider">'
+ ' <th class="pr-4" data-i18n="logs.table.server">Server</th>'
+ ' <th data-i18n="logs.table.count">Count</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody>';
if (!statsKeys.length) {
html += '<tr><td colspan="2" class="py-2 text-sm text-gray-500" data-i18n="logs.overview.per_server_empty">No per-server data available yet.</td></tr>';
} else {
statsKeys.forEach(function(serverId) {
var count = latestBanStats[serverId] || 0;
var server = serversCache.find(function(s) { return s.id === serverId; });
html += ''
+ ' <tr>'
+ ' <td class="pr-4 py-1">' + escapeHtml(server ? server.name : serverId) + '</td>'
+ ' <td class="py-1">' + count + '</td>'
+ ' </tr>';
});
}
html += ' </tbody></table></div></div>';
}
html += '<h4 class="text-md font-semibold text-gray-800 mb-3" data-i18n="logs.overview.recent_events_title">Recent stored events</h4>';
if (!latestBanEvents.length) {
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_empty">No stored events found.</p>';
} else {
var countries = getBanEventCountries();
var filteredEvents = getFilteredBanEvents();
var recurringMap = getRecurringIPMap();
var searchQuery = (banEventsFilterText || '').trim();
html += ''
+ '<div class="flex flex-col sm:flex-row gap-3 mb-4">'
+ ' <div class="flex-1">'
+ ' <label for="recentEventsSearch" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="logs.search.label">Search events</label>'
+ ' <input type="text" id="recentEventsSearch" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="' + t('logs.search.placeholder', 'Search IP, jail or server') + '" value="' + escapeHtml(banEventsFilterText) + '" oninput="updateBanEventsSearch(this.value)">'
+ ' </div>'
+ ' <div class="w-full sm:w-48">'
+ ' <label for="recentEventsCountry" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="logs.search.country_label">Country</label>'
+ ' <select id="recentEventsCountry" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="updateBanEventsCountry(this.value)">'
+ ' <option value="all"' + (banEventsFilterCountry === 'all' ? ' selected' : '') + ' data-i18n="logs.search.country_all">All countries</option>';
countries.forEach(function(country) {
var value = (country || '').trim();
var optionValue = value ? value.toLowerCase() : '__unknown__';
var label = value || t('logs.search.country_unknown', 'Unknown');
var selected = banEventsFilterCountry.toLowerCase() === optionValue ? ' selected' : '';
html += '<option value="' + optionValue + '"' + selected + '>' + escapeHtml(label) + '</option>';
});
html += ' </select>'
+ ' </div>'
+ '</div>';
html += '<p class="text-xs text-gray-500 mb-3">' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + filteredEvents.length + ' / ' + latestBanEvents.length + '</p>';
if (!filteredEvents.length) {
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_filtered_empty">No stored events match the current filters.</p>';
} else {
html += ''
+ '<div class="overflow-x-auto">'
+ ' <table class="min-w-full divide-y divide-gray-200 text-sm">'
+ ' <thead class="bg-gray-50">'
+ ' <tr>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.time">Time</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.server">Server</th>'
+ ' <th class="hidden sm:table-cell px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.jail">Jail</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.ip">IP</th>'
+ ' <th class="hidden md:table-cell px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.country">Country</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.actions">Actions</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody class="bg-white divide-y divide-gray-200">';
filteredEvents.forEach(function(event) {
var index = latestBanEvents.indexOf(event);
var hasWhois = event.whois && event.whois.trim().length > 0;
var hasLogs = event.logs && event.logs.trim().length > 0;
var serverValue = event.serverName || event.serverId || '';
var jailValue = event.jail || '';
var ipValue = event.ip || '';
var serverCell = highlightQueryMatch(serverValue, searchQuery);
var jailCell = highlightQueryMatch(jailValue, searchQuery);
var ipCell = highlightQueryMatch(ipValue, searchQuery);
if (event.ip && recurringMap[event.ip]) {
ipCell += ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">' + t('logs.badge.recurring', 'Recurring') + '</span>';
}
html += ''
+ ' <tr class="hover:bg-gray-50">'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(formatDateTime(event.occurredAt || event.createdAt)) + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + serverCell + '</td>'
+ ' <td class="hidden sm:table-cell px-2 py-2 whitespace-nowrap">' + jailCell + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + ipCell + '</td>'
+ ' <td class="hidden md:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.country || '—') + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">'
+ ' <div class="flex gap-2">'
+ (hasWhois ? ' <button onclick="openWhoisModal(' + index + ')" class="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700" data-i18n="logs.actions.whois">Whois</button>' : ' <button disabled class="px-2 py-1 text-xs bg-gray-300 text-gray-500 rounded cursor-not-allowed" data-i18n="logs.actions.whois">Whois</button>')
+ (hasLogs ? ' <button onclick="openLogsModal(' + index + ')" class="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700" data-i18n="logs.actions.logs">Logs</button>' : ' <button disabled class="px-2 py-1 text-xs bg-gray-300 text-gray-500 rounded cursor-not-allowed" data-i18n="logs.actions.logs">Logs</button>')
+ ' </div>'
+ ' </td>'
+ ' </tr>';
});
html += ' </tbody></table></div>';
}
}
html += '</div>';
return html;
}

View File

@@ -0,0 +1,123 @@
// Filter debug functions for Fail2ban UI
"use strict";
function loadFilters() {
showLoading(true);
fetch(withServerParam('/api/filters'), {
headers: serverHeaders()
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error loading filters: ' + data.error, 'error');
return;
}
const select = document.getElementById('filterSelect');
const notice = document.getElementById('filterNotice');
if (notice) {
if (data.messageKey) {
notice.classList.remove('hidden');
notice.textContent = t(data.messageKey, data.message || '');
} else {
notice.classList.add('hidden');
notice.textContent = '';
}
}
select.innerHTML = '';
if (!data.filters || data.filters.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No Filters Found';
select.appendChild(opt);
} else {
data.filters.forEach(f => {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = f;
select.appendChild(opt);
});
}
})
.catch(err => {
showToast('Error loading filters: ' + err, 'error');
})
.finally(() => showLoading(false));
}
function testSelectedFilter() {
const filterName = document.getElementById('filterSelect').value;
const lines = document.getElementById('logLinesTextarea').value.split('\n').filter(line => line.trim() !== '');
if (!filterName) {
showToast('Please select a filter.', 'info');
return;
}
if (lines.length === 0) {
showToast('Please enter at least one log line to test.', 'info');
return;
}
// Hide results initially
const testResultsEl = document.getElementById('testResults');
testResultsEl.classList.add('hidden');
testResultsEl.innerHTML = '';
showLoading(true);
fetch(withServerParam('/api/filters/test'), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
filterName: filterName,
logLines: lines
})
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error testing filter: ' + data.error, 'error');
return;
}
renderTestResults(data.output || '');
})
.catch(err => {
showToast('Error testing filter: ' + err, 'error');
})
.finally(() => showLoading(false));
}
function renderTestResults(output) {
const testResultsEl = document.getElementById('testResults');
let html = '<h5 class="text-lg font-medium text-white mb-4" data-i18n="filter_debug.test_results_title">Test Results</h5>';
if (!output || output.trim() === '') {
html += '<p class="text-gray-400" data-i18n="filter_debug.no_matches">No output received.</p>';
} else {
html += '<pre class="text-white whitespace-pre-wrap overflow-x-auto">' + escapeHtml(output) + '</pre>';
}
testResultsEl.innerHTML = html;
testResultsEl.classList.remove('hidden');
if (typeof updateTranslations === 'function') {
updateTranslations();
}
}
function showFilterSection() {
const testResultsEl = document.getElementById('testResults');
if (!currentServerId) {
var notice = document.getElementById('filterNotice');
if (notice) {
notice.classList.remove('hidden');
notice.textContent = t('filter_debug.not_available', 'Filter debug is only available when a Fail2ban server is selected.');
}
document.getElementById('filterSelect').innerHTML = '';
document.getElementById('logLinesTextarea').value = '';
testResultsEl.innerHTML = '';
testResultsEl.classList.add('hidden');
return;
}
loadFilters();
testResultsEl.innerHTML = '';
testResultsEl.classList.add('hidden');
document.getElementById('logLinesTextarea').value = '';
}

View File

@@ -0,0 +1,24 @@
// Global variables for Fail2ban UI
"use strict";
var currentJailForConfig = null;
var serversCache = [];
var currentServerId = null;
var currentServer = null;
var latestSummary = null;
var latestSummaryError = null;
var latestBanStats = {};
var latestBanEvents = [];
var latestBanInsights = {
totals: { overall: 0, today: 0, week: 0 },
countries: [],
recurring: []
};
var latestServerInsights = null;
var banEventsFilterText = '';
var banEventsFilterCountry = 'all';
var banEventsFilterDebounce = null;
var translations = {};
var sshKeysCache = null;
var openModalCount = 0;
var isLOTRModeActive = false;

View File

@@ -0,0 +1,105 @@
// Ignore IPs tag management functions for Fail2ban UI
"use strict";
function renderIgnoreIPsTags(ips) {
const container = document.getElementById('ignoreIPsTags');
if (!container) return;
container.innerHTML = '';
if (ips && ips.length > 0) {
ips.forEach(function(ip) {
if (ip && ip.trim()) {
addIgnoreIPTag(ip.trim());
}
});
}
}
function addIgnoreIPTag(ip) {
if (!ip || !ip.trim()) return;
const trimmedIP = ip.trim();
// Validate IP before adding - isValidIP is in validation.js
if (typeof isValidIP === 'function' && !isValidIP(trimmedIP)) {
if (typeof showToast === 'function') {
showToast('Invalid IP address, CIDR, or hostname: ' + trimmedIP, 'error');
}
return;
}
const container = document.getElementById('ignoreIPsTags');
if (!container) return;
const existingTags = Array.from(container.querySelectorAll('.ignore-ip-tag')).map(tag => tag.dataset.ip);
if (existingTags.includes(trimmedIP)) {
return; // Already exists
}
const tag = document.createElement('span');
tag.className = 'ignore-ip-tag inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800';
tag.dataset.ip = trimmedIP;
const escapedIP = escapeHtml(trimmedIP);
tag.innerHTML = escapedIP + ' <button type="button" class="ml-1 text-blue-600 hover:text-blue-800 focus:outline-none" onclick="removeIgnoreIPTag(\'' + escapedIP.replace(/'/g, "\\'") + '\')">×</button>';
container.appendChild(tag);
// Clear input
const input = document.getElementById('ignoreIPInput');
if (input) input.value = '';
}
function removeIgnoreIPTag(ip) {
const container = document.getElementById('ignoreIPsTags');
if (!container) return;
const escapedIP = escapeHtml(ip);
const tag = container.querySelector('.ignore-ip-tag[data-ip="' + escapedIP.replace(/"/g, '&quot;') + '"]');
if (tag) {
tag.remove();
}
}
function getIgnoreIPsArray() {
const container = document.getElementById('ignoreIPsTags');
if (!container) return [];
const tags = container.querySelectorAll('.ignore-ip-tag');
return Array.from(tags).map(tag => tag.dataset.ip).filter(ip => ip && ip.trim());
}
function setupIgnoreIPsInput() {
const input = document.getElementById('ignoreIPInput');
if (!input) return;
// Prevent typing invalid characters - only allow valid IP/hostname characters
let lastValue = '';
input.addEventListener('input', function(e) {
// Filter out invalid characters but allow valid IP/hostname characters
// Allow: 0-9, a-z, A-Z, :, ., /, -, _ (for hostnames)
let value = this.value;
// Remove any characters that aren't valid for IPs/hostnames
const filtered = value.replace(/[^0-9a-zA-Z:.\/\-_]/g, '');
if (value !== filtered) {
this.value = filtered;
}
lastValue = filtered;
});
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const value = input.value.trim();
if (value) {
// Support space or comma separated IPs
const ips = value.split(/[,\s]+/).filter(ip => ip.trim());
ips.forEach(ip => addIgnoreIPTag(ip.trim()));
}
}
});
input.addEventListener('blur', function(e) {
const value = input.value.trim();
if (value) {
const ips = value.split(/[,\s]+/).filter(ip => ip.trim());
ips.forEach(ip => addIgnoreIPTag(ip.trim()));
}
});
}

117
pkg/web/static/js/init.js Normal file
View File

@@ -0,0 +1,117 @@
// Initialization code for Fail2ban UI
"use strict";
window.addEventListener('DOMContentLoaded', function() {
showLoading(true);
displayExternalIP();
// Check LOTR mode on page load to apply immediately
fetch('/api/settings')
.then(res => res.json())
.then(data => {
const alertCountries = data.alertCountries || [];
if (typeof checkAndApplyLOTRTheme === 'function') {
checkAndApplyLOTRTheme(alertCountries);
}
// Store in global for later use
if (typeof currentSettings === 'undefined') {
window.currentSettings = {};
}
window.currentSettings.alertCountries = alertCountries;
})
.catch(err => {
console.warn('Could not check LOTR on load:', err);
});
Promise.all([
loadServers(),
getTranslationsSettingsOnPageload()
])
.then(function() {
updateRestartBanner();
if (typeof refreshData === 'function') {
return refreshData({ silent: true });
}
})
.catch(function(err) {
console.error('Initialization error:', err);
latestSummaryError = err ? err.toString() : 'failed to initialize';
if (typeof renderDashboard === 'function') {
renderDashboard();
}
})
.finally(function() {
initializeTooltips(); // Initialize tooltips after fetching and rendering
initializeSearch();
showLoading(false);
});
// Setup Select2 for alert countries
$(document).ready(function() {
$('#alertCountries').select2({
placeholder: 'Select countries..',
allowClear: true,
width: '100%'
});
$('#alertCountries').on('select2:select', function(e) {
var selectedValue = e.params.data.id;
var currentValues = $('#alertCountries').val() || [];
if (selectedValue === 'ALL') {
if (currentValues.length > 1) {
$('#alertCountries').val(['ALL']).trigger('change');
}
} else {
if (currentValues.indexOf('ALL') !== -1) {
var newValues = currentValues.filter(function(value) {
return value !== 'ALL';
});
$('#alertCountries').val(newValues).trigger('change');
}
}
// Check LOTR mode after selection change
setTimeout(function() {
const selectedCountries = $('#alertCountries').val() || [];
if (typeof checkAndApplyLOTRTheme === 'function') {
checkAndApplyLOTRTheme(selectedCountries);
}
}, 100);
});
$('#alertCountries').on('select2:unselect', function(e) {
// Check LOTR mode after deselection
setTimeout(function() {
const selectedCountries = $('#alertCountries').val() || [];
if (typeof checkAndApplyLOTRTheme === 'function') {
checkAndApplyLOTRTheme(selectedCountries);
}
}, 100);
});
var sshKeySelect = document.getElementById('serverSSHKeySelect');
if (sshKeySelect) {
sshKeySelect.addEventListener('change', function(e) {
if (e.target.value) {
document.getElementById('serverSSHKey').value = e.target.value;
}
});
}
// Setup IgnoreIPs tag input
if (typeof setupIgnoreIPsInput === 'function') {
setupIgnoreIPsInput();
}
// Setup form validation
if (typeof setupFormValidation === 'function') {
setupFormValidation();
}
// Setup advanced integration fields
const advancedIntegrationSelect = document.getElementById('advancedIntegrationSelect');
if (advancedIntegrationSelect && typeof updateAdvancedIntegrationFields === 'function') {
advancedIntegrationSelect.addEventListener('change', updateAdvancedIntegrationFields);
}
});
});

327
pkg/web/static/js/jails.js Normal file
View File

@@ -0,0 +1,327 @@
// Jail management functions for Fail2ban UI
"use strict";
function preventExtensionInterference(element) {
if (!element) return;
try {
// Ensure control property exists to prevent "Cannot read properties of undefined" errors
if (!element.control) {
Object.defineProperty(element, 'control', {
value: {
type: element.type || 'textarea',
name: element.name || 'filter-config-editor',
form: null,
autocomplete: 'off'
},
writable: false,
enumerable: false,
configurable: true
});
}
// Prevent extensions from adding their own properties
Object.seal(element.control);
} catch (e) {
// Silently ignore errors
}
}
function openJailConfigModal(jailName) {
currentJailForConfig = jailName;
var filterTextArea = document.getElementById('filterConfigTextarea');
var jailTextArea = document.getElementById('jailConfigTextarea');
filterTextArea.value = '';
jailTextArea.value = '';
// Prevent browser extensions from interfering
preventExtensionInterference(filterTextArea);
preventExtensionInterference(jailTextArea);
document.getElementById('modalJailName').textContent = jailName;
// Hide test logpath section initially
document.getElementById('testLogpathSection').classList.add('hidden');
document.getElementById('logpathResults').classList.add('hidden');
showLoading(true);
var url = '/api/jails/' + encodeURIComponent(jailName) + '/config';
fetch(withServerParam(url), {
headers: serverHeaders()
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast("Error loading config: " + data.error, 'error');
return;
}
filterTextArea.value = data.filter || '';
jailTextArea.value = data.jailConfig || '';
// Check if logpath is set in jail config and show test button
updateLogpathButtonVisibility();
// Add listener to update button visibility when jail config changes
jailTextArea.addEventListener('input', updateLogpathButtonVisibility);
// Prevent extension interference before opening modal
preventExtensionInterference(filterTextArea);
preventExtensionInterference(jailTextArea);
openModal('jailConfigModal');
// Setup syntax highlighting for both textareas after modal is visible
setTimeout(function() {
preventExtensionInterference(filterTextArea);
preventExtensionInterference(jailTextArea);
}, 200);
})
.catch(function(err) {
showToast("Error: " + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function updateLogpathButtonVisibility() {
var jailTextArea = document.getElementById('jailConfigTextarea');
var jailConfig = jailTextArea ? jailTextArea.value : '';
var hasLogpath = /logpath\s*=/i.test(jailConfig);
var testSection = document.getElementById('testLogpathSection');
if (hasLogpath && testSection) {
testSection.classList.remove('hidden');
} else if (testSection) {
testSection.classList.add('hidden');
document.getElementById('logpathResults').classList.add('hidden');
}
}
function saveJailConfig() {
if (!currentJailForConfig) return;
showLoading(true);
var filterConfig = document.getElementById('filterConfigTextarea').value;
var jailConfig = document.getElementById('jailConfigTextarea').value;
var url = '/api/jails/' + encodeURIComponent(currentJailForConfig) + '/config';
fetch(withServerParam(url), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ filter: filterConfig, jail: jailConfig }),
})
.then(function(res) {
if (!res.ok) {
return res.json().then(function(data) {
throw new Error(data.error || 'Server returned ' + res.status);
});
}
return res.json();
})
.then(function(data) {
if (data.error) {
showToast("Error saving config: " + data.error, 'error');
return;
}
closeModal('jailConfigModal');
showToast(t('filter_debug.save_success', 'Filter and jail config saved and reloaded'), 'success');
return refreshData({ silent: true });
})
.catch(function(err) {
console.error("Error saving config:", err);
showToast("Error saving config: " + err.message, 'error');
})
.finally(function() {
showLoading(false);
});
}
function testLogpath() {
if (!currentJailForConfig) return;
var resultsDiv = document.getElementById('logpathResults');
resultsDiv.textContent = 'Testing logpath...';
resultsDiv.classList.remove('hidden');
resultsDiv.classList.remove('text-red-600', 'text-yellow-600');
showLoading(true);
var url = '/api/jails/' + encodeURIComponent(currentJailForConfig) + '/logpath/test';
fetch(withServerParam(url), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
})
.then(function(res) { return res.json(); })
.then(function(data) {
showLoading(false);
if (data.error) {
resultsDiv.textContent = 'Error: ' + data.error;
resultsDiv.classList.add('text-red-600');
return;
}
var files = data.files || [];
if (files.length === 0) {
resultsDiv.textContent = 'No files found for logpath: ' + (data.logpath || 'N/A');
resultsDiv.classList.remove('text-red-600');
resultsDiv.classList.add('text-yellow-600');
} else {
resultsDiv.textContent = 'Found ' + files.length + ' file(s):\n' + files.join('\n');
resultsDiv.classList.remove('text-red-600', 'text-yellow-600');
}
})
.catch(function(err) {
showLoading(false);
resultsDiv.textContent = 'Error: ' + err;
resultsDiv.classList.add('text-red-600');
});
}
function openManageJailsModal() {
if (!currentServerId) {
showToast(t('servers.selector.none', 'Please add and select a Fail2ban server first.'), 'info');
return;
}
showLoading(true);
fetch(withServerParam('/api/jails/manage'), {
headers: serverHeaders()
})
.then(res => res.json())
.then(data => {
if (!data.jails || !data.jails.length) {
showToast("No jails found for this server.", 'info');
return;
}
const html = data.jails.map(jail => {
const isEnabled = jail.enabled ? 'checked' : '';
const escapedJailName = escapeHtml(jail.jailName);
// Escape single quotes for JavaScript string
const jsEscapedJailName = jail.jailName.replace(/'/g, "\\'");
return ''
+ '<div class="flex items-center justify-between gap-3 p-3 bg-gray-50">'
+ ' <span class="text-sm font-medium flex-1">' + escapedJailName + '</span>'
+ ' <div class="flex items-center gap-3">'
+ ' <button'
+ ' type="button"'
+ ' onclick="openJailConfigModal(\'' + jsEscapedJailName + '\')"'
+ ' class="text-xs px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors whitespace-nowrap"'
+ ' data-i18n="modal.filter_config_edit"'
+ ' title="' + escapeHtml(t('modal.filter_config_edit', 'Edit Filter')) + '"'
+ ' >'
+ escapeHtml(t('modal.filter_config_edit', 'Edit Filter'))
+ ' </button>'
+ ' <label class="inline-flex relative items-center cursor-pointer">'
+ ' <input'
+ ' type="checkbox"'
+ ' id="toggle-' + jail.jailName.replace(/[^a-zA-Z0-9]/g, '_') + '"'
+ ' class="sr-only peer"'
+ isEnabled
+ ' />'
+ ' <div'
+ ' class="w-11 h-6 bg-gray-200 rounded-full peer-focus:ring-4 peer-focus:ring-blue-300 peer-checked:bg-blue-600 transition-colors"'
+ ' ></div>'
+ ' <span'
+ ' class="absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform peer-checked:translate-x-5"'
+ ' ></span>'
+ ' </label>'
+ ' </div>'
+ '</div>';
}).join('');
document.getElementById('jailsList').innerHTML = html;
// Add auto-save on checkbox change with debouncing
let saveTimeout;
document.querySelectorAll('#jailsList input[type="checkbox"]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
// Clear any pending save
if (saveTimeout) {
clearTimeout(saveTimeout);
}
// Debounce save by 300ms
saveTimeout = setTimeout(function() {
saveManageJailsSingle(checkbox);
}, 300);
});
});
openModal('manageJailsModal');
})
.catch(err => showToast("Error fetching jails: " + err, 'error'))
.finally(() => showLoading(false));
}
function saveManageJailsSingle(checkbox) {
// Find the parent container div
const item = checkbox.closest('div.flex.items-center.justify-between');
if (!item) {
console.error('Could not find parent container for checkbox');
return;
}
// Get jail name from the span - it's the first span with text-sm font-medium class
const nameSpan = item.querySelector('span.text-sm.font-medium');
if (!nameSpan) {
console.error('Could not find jail name span');
return;
}
const jailName = nameSpan.textContent.trim();
if (!jailName) {
console.error('Jail name is empty');
return;
}
const isEnabled = checkbox.checked;
const updatedJails = {};
updatedJails[jailName] = isEnabled;
console.log('Saving jail state:', jailName, 'enabled:', isEnabled, 'payload:', updatedJails);
// Send updated state to the API endpoint /api/jails/manage.
fetch(withServerParam('/api/jails/manage'), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(updatedJails),
})
.then(function(res) {
if (!res.ok) {
return res.json().then(function(data) {
throw new Error(data.error || 'Server returned ' + res.status);
});
}
return res.json();
})
.then(function(data) {
if (data.error) {
showToast("Error saving jail settings: " + data.error, 'error');
// Revert checkbox state on error
checkbox.checked = !isEnabled;
return;
}
console.log('Jail state saved successfully:', data);
// Show success toast
showToast('Jail ' + jailName + ' ' + (isEnabled ? 'enabled' : 'disabled') + ' successfully', 'success');
// Reload the jail list to reflect the actual state
return fetch(withServerParam('/api/jails/manage'), {
headers: serverHeaders()
}).then(function(res) { return res.json(); })
.then(function(data) {
if (data.jails && data.jails.length) {
// Update the checkbox state based on server response
const jail = data.jails.find(function(j) { return j.jailName === jailName; });
if (jail) {
checkbox.checked = jail.enabled;
}
}
loadServers().then(function() {
updateRestartBanner();
return refreshData({ silent: true });
});
});
})
.catch(function(err) {
console.error('Error saving jail settings:', err);
showToast("Error saving jail settings: " + (err.message || err), 'error');
// Revert checkbox state on error
checkbox.checked = !isEnabled;
});
}

154
pkg/web/static/js/lotr.js Normal file
View File

@@ -0,0 +1,154 @@
// LOTR Mode functions for Fail2ban UI
"use strict";
function isLOTRMode(alertCountries) {
if (!alertCountries || !Array.isArray(alertCountries)) {
return false;
}
return alertCountries.includes('LOTR');
}
function applyLOTRTheme(active) {
const body = document.body;
const lotrCSS = document.getElementById('lotr-css');
if (active) {
// Enable CSS first
if (lotrCSS) {
lotrCSS.disabled = false;
}
// Then add class to body
body.classList.add('lotr-mode');
isLOTRModeActive = true;
console.log('🎭 LOTR Mode Activated - Welcome to Middle-earth!');
} else {
// Remove class first
body.classList.remove('lotr-mode');
// Then disable CSS
if (lotrCSS) {
lotrCSS.disabled = true;
}
isLOTRModeActive = false;
console.log('🎭 LOTR Mode Deactivated');
}
// Force a reflow to ensure styles are applied
void body.offsetHeight;
}
function checkAndApplyLOTRTheme(alertCountries) {
const shouldBeActive = isLOTRMode(alertCountries);
if (shouldBeActive !== isLOTRModeActive) {
applyLOTRTheme(shouldBeActive);
updateLOTRTerminology(shouldBeActive);
}
}
function updateLOTRTerminology(active) {
if (active) {
// Update navigation title
const navTitle = document.querySelector('nav .text-xl');
if (navTitle) {
navTitle.textContent = 'Middle-earth Security';
}
// Update page title
const pageTitle = document.querySelector('title');
if (pageTitle) {
pageTitle.textContent = 'Middle-earth Security Realm';
}
// Update dashboard terminology
updateDashboardLOTRTerminology(true);
// Add decorative elements
addLOTRDecorations();
} else {
// Restore original text
const navTitle = document.querySelector('nav .text-xl');
if (navTitle) {
navTitle.textContent = 'Fail2ban UI';
}
const pageTitle = document.querySelector('title');
if (pageTitle && pageTitle.hasAttribute('data-i18n')) {
const i18nKey = pageTitle.getAttribute('data-i18n');
pageTitle.textContent = t(i18nKey, 'Fail2ban UI Dashboard');
}
// Restore dashboard terminology
updateDashboardLOTRTerminology(false);
// Remove decorative elements
removeLOTRDecorations();
}
}
function updateDashboardLOTRTerminology(active) {
// Update text elements that use data-i18n
const elements = document.querySelectorAll('[data-i18n]');
elements.forEach(el => {
const i18nKey = el.getAttribute('data-i18n');
if (active) {
// Check for LOTR-specific translations
if (i18nKey === 'dashboard.cards.total_banned') {
el.textContent = t('lotr.threats_banished', 'Threats Banished');
} else if (i18nKey === 'dashboard.table.banned_ips') {
el.textContent = t('lotr.threats_banished', 'Threats Banished');
} else if (i18nKey === 'dashboard.search_label') {
el.textContent = t('lotr.threats_banished', 'Search Banished Threats');
} else if (i18nKey === 'dashboard.manage_servers') {
el.textContent = t('lotr.realms_protected', 'Manage Realms');
}
} else {
// Restore original translations
if (i18nKey) {
el.textContent = t(i18nKey, el.textContent);
}
}
});
// Update "Unban" buttons
const unbanButtons = document.querySelectorAll('button, a');
unbanButtons.forEach(btn => {
if (btn.textContent && btn.textContent.includes('Unban')) {
if (active) {
btn.textContent = btn.textContent.replace(/Unban/gi, t('lotr.banished', 'Restore to Realm'));
} else {
btn.textContent = btn.textContent.replace(/Restore to Realm/gi, t('dashboard.unban', 'Unban'));
}
}
});
}
function addLOTRDecorations() {
// Add decorative divider to settings section if not already present
const settingsSection = document.getElementById('settingsSection');
if (settingsSection && !settingsSection.querySelector('.lotr-divider')) {
const divider = document.createElement('div');
divider.className = 'lotr-divider';
divider.style.marginTop = '20px';
divider.style.marginBottom = '20px';
// Find the first child element (not text node) to insert before
const firstChild = Array.from(settingsSection.childNodes).find(
node => node.nodeType === Node.ELEMENT_NODE
);
if (firstChild && firstChild.parentNode === settingsSection) {
settingsSection.insertBefore(divider, firstChild);
} else if (settingsSection.firstChild) {
// Fallback: append if insertBefore fails
settingsSection.insertBefore(divider, settingsSection.firstChild);
} else {
// Last resort: append to end
settingsSection.appendChild(divider);
}
}
}
function removeLOTRDecorations() {
const dividers = document.querySelectorAll('.lotr-divider');
dividers.forEach(div => div.remove());
}

235
pkg/web/static/js/modals.js Normal file
View File

@@ -0,0 +1,235 @@
// Modal management functions for Fail2ban UI
"use strict";
function updateBodyScrollLock() {
if (openModalCount > 0) {
document.body.classList.add('modal-open');
} else {
document.body.classList.remove('modal-open');
}
}
// Close modal
function closeModal(modalId) {
var modal = document.getElementById(modalId);
if (!modal || modal.classList.contains('hidden')) {
return;
}
modal.classList.add('hidden');
openModalCount = Math.max(0, openModalCount - 1);
updateBodyScrollLock();
}
// Open modal
function openModal(modalId) {
var modal = document.getElementById(modalId);
if (!modal || !modal.classList.contains('hidden')) {
updateBodyScrollLock();
return;
}
modal.classList.remove('hidden');
openModalCount += 1;
updateBodyScrollLock();
}
function openWhoisModal(eventIndex) {
if (!latestBanEvents || !latestBanEvents[eventIndex]) {
showToast("Event not found", 'error');
return;
}
var event = latestBanEvents[eventIndex];
if (!event.whois || !event.whois.trim()) {
showToast("No whois data available for this event", 'info');
return;
}
document.getElementById('whoisModalIP').textContent = event.ip || 'N/A';
var contentEl = document.getElementById('whoisModalContent');
contentEl.textContent = event.whois;
openModal('whoisModal');
}
function openLogsModal(eventIndex) {
if (!latestBanEvents || !latestBanEvents[eventIndex]) {
showToast("Event not found", 'error');
return;
}
var event = latestBanEvents[eventIndex];
if (!event.logs || !event.logs.trim()) {
showToast("No logs data available for this event", 'info');
return;
}
document.getElementById('logsModalIP').textContent = event.ip || 'N/A';
document.getElementById('logsModalJail').textContent = event.jail || 'N/A';
var logs = event.logs;
var ip = event.ip || '';
var logLines = logs.split('\n');
// Determine which lines are suspicious (bad requests)
var suspiciousIndices = [];
for (var i = 0; i < logLines.length; i++) {
if (isSuspiciousLogLine(logLines[i], ip)) {
suspiciousIndices.push(i);
}
}
var contentEl = document.getElementById('logsModalContent');
if (suspiciousIndices.length) {
var highlightMap = {};
suspiciousIndices.forEach(function(idx) { highlightMap[idx] = true; });
var html = '';
for (var j = 0; j < logLines.length; j++) {
var safeLine = escapeHtml(logLines[j] || '');
if (highlightMap[j]) {
html += '<span style="display: block; background-color: #d97706; color: #fef3c7; padding: 0.25rem 0.5rem; margin: 0.125rem 0; border-radius: 0.25rem;">' + safeLine + '</span>';
} else {
html += safeLine + '\n';
}
}
contentEl.innerHTML = html;
} else {
// No suspicious lines detected; show raw logs without highlighting
contentEl.textContent = logs;
}
openModal('logsModal');
}
function isSuspiciousLogLine(line, ip) {
if (!line) {
return false;
}
var containsIP = ip && line.indexOf(ip) !== -1;
var lowered = line.toLowerCase();
// Detect HTTP status codes (>= 300 considered problematic)
var statusMatch = line.match(/"[^"]*"\s+(\d{3})\b/);
if (!statusMatch) {
statusMatch = line.match(/\s(\d{3})\s+(?:\d+|-)/);
}
var statusCode = statusMatch ? parseInt(statusMatch[1], 10) : NaN;
var hasBadStatus = !isNaN(statusCode) && statusCode >= 300;
// Detect common attack indicators in URLs/payloads
var indicators = [
'../',
'%2e%2e',
'%252e%252e',
'%24%7b',
'${',
'/etc/passwd',
'select%20',
'union%20',
'cmd=',
'wget',
'curl ',
'nslookup',
'/xmlrpc.php',
'/wp-admin',
'/cgi-bin',
'content-length: 0'
];
var hasIndicator = indicators.some(function(ind) {
return lowered.indexOf(ind) !== -1;
});
if (containsIP) {
return hasBadStatus || hasIndicator;
}
return (hasBadStatus || hasIndicator) && !ip;
}
function openBanInsightsModal() {
var countriesContainer = document.getElementById('countryStatsContainer');
var recurringContainer = document.getElementById('recurringIPsContainer');
var summaryContainer = document.getElementById('insightsSummary');
var totals = (latestBanInsights && latestBanInsights.totals) || { overall: 0, today: 0, week: 0 };
if (summaryContainer) {
var summaryCards = [
{
label: t('logs.overview.total_events', 'Total stored events'),
value: formatNumber(totals.overall || 0),
sub: t('logs.modal.total_overall_note', 'Lifetime bans recorded')
},
{
label: t('logs.overview.total_today', 'Today'),
value: formatNumber(totals.today || 0),
sub: t('logs.modal.total_today_note', 'Last 24 hours')
},
{
label: t('logs.overview.total_week', 'Last 7 days'),
value: formatNumber(totals.week || 0),
sub: t('logs.modal.total_week_note', 'Weekly activity')
}
];
summaryContainer.innerHTML = summaryCards.map(function(card) {
return ''
+ '<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">'
+ ' <p class="text-xs uppercase tracking-wide text-gray-500">' + escapeHtml(card.label) + '</p>'
+ ' <p class="text-3xl font-semibold text-gray-900 mt-1">' + escapeHtml(card.value) + '</p>'
+ ' <p class="text-xs text-gray-500 mt-1">' + escapeHtml(card.sub) + '</p>'
+ '</div>';
}).join('');
}
var countries = (latestBanInsights && latestBanInsights.countries) || [];
if (!countries.length) {
countriesContainer.innerHTML = '<p class="text-sm text-gray-500" data-i18n="logs.modal.insights_countries_empty">No bans recorded for this period.</p>';
} else {
var totalCountries = countries.reduce(function(sum, stat) {
return sum + (stat.count || 0);
}, 0) || 1;
var countryHTML = countries.map(function(stat) {
var label = stat.country || t('logs.overview.country_unknown', 'Unknown');
var percent = Math.round(((stat.count || 0) / totalCountries) * 100);
percent = Math.min(Math.max(percent, 3), 100);
return ''
+ '<div class="space-y-2">'
+ ' <div class="flex items-center justify-between text-sm font-medium text-gray-800" style="border-bottom: ridge;">'
+ ' <span>' + escapeHtml(label) + '</span>'
+ ' <span>' + formatNumber(stat.count || 0) + '</span>'
+ ' </div>'
+ ' <div class="w-full bg-gray-200 rounded-full h-2">'
+ ' <div class="h-2 rounded-full bg-gradient-to-r from-blue-500 to-indigo-600" style="width:' + percent + '%;"></div>'
+ ' </div>'
+ '</div>';
}).join('');
countriesContainer.innerHTML = countryHTML;
}
var recurring = (latestBanInsights && latestBanInsights.recurring) || [];
if (!recurring.length) {
recurringContainer.innerHTML = '<p class="text-sm text-gray-500" data-i18n="logs.modal.insights_recurring_empty">No recurring IPs detected.</p>';
} else {
var recurringHTML = recurring.map(function(stat) {
var countryLabel = stat.country || t('logs.overview.country_unknown', 'Unknown');
var lastSeenLabel = stat.lastSeen ? formatDateTime(stat.lastSeen) : '—';
return ''
+ '<div class="rounded-lg bg-white border border-gray-200 shadow-sm p-4">'
+ ' <div class="flex items-center justify-between">'
+ ' <div>'
+ ' <p class="font-mono text-base text-gray-900">' + escapeHtml(stat.ip || '—') + '</p>'
+ ' <p class="text-xs text-gray-500 mt-1">' + escapeHtml(countryLabel) + '</p>'
+ ' </div>'
+ ' <span class="inline-flex items-center rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700">' + formatNumber(stat.count || 0) + '×</span>'
+ ' </div>'
+ ' <div class="mt-3 flex justify-between text-xs text-gray-500">'
+ ' <span>' + t('logs.overview.last_seen', 'Last seen') + '</span>'
+ ' <span>' + escapeHtml(lastSeenLabel) + '</span>'
+ ' </div>'
+ '</div>';
}).join('');
recurringContainer.innerHTML = recurringHTML;
}
if (typeof updateTranslations === 'function') {
updateTranslations();
}
openModal('banInsightsModal');
}

View File

@@ -0,0 +1,561 @@
// Server management functions for Fail2ban UI
"use strict";
function loadServers() {
return fetch('/api/servers')
.then(function(res) { return res.json(); })
.then(function(data) {
serversCache = data.servers || [];
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
if (!enabledServers.length) {
currentServerId = null;
currentServer = null;
} else {
var desired = currentServerId;
var selected = desired ? enabledServers.find(function(s) { return s.id === desired; }) : null;
if (!selected) {
var def = enabledServers.find(function(s) { return s.isDefault; });
selected = def || enabledServers[0];
}
currentServer = selected;
currentServerId = selected ? selected.id : null;
}
renderServerSelector();
renderServerSubtitle();
updateRestartBanner();
})
.catch(function(err) {
console.error('Error loading servers:', err);
serversCache = [];
currentServerId = null;
currentServer = null;
renderServerSelector();
renderServerSubtitle();
updateRestartBanner();
});
}
function renderServerSelector() {
var container = document.getElementById('serverSelectorContainer');
if (!container) return;
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
if (!serversCache.length) {
container.innerHTML = '<div class="text-sm text-red-500" data-i18n="servers.selector.empty">No servers configured</div>';
if (typeof updateTranslations === 'function') {
updateTranslations();
}
return;
}
if (!enabledServers.length) {
container.innerHTML = '<div class="text-sm text-red-500" data-i18n="servers.selector.empty">No servers configured</div>';
if (typeof updateTranslations === 'function') {
updateTranslations();
}
return;
}
var options = enabledServers.map(function(server) {
var label = escapeHtml(server.name || server.id);
var type = server.type ? (' (' + server.type.toUpperCase() + ')') : '';
return '<option value="' + escapeHtml(server.id) + '">' + label + type + '</option>';
}).join('');
container.innerHTML = ''
+ '<div class="flex flex-col">'
+ ' <label for="serverSelect" class="text-xs text-gray-500 mb-1" data-i18n="servers.selector.label">Active Server</label>'
+ ' <select id="serverSelect" class="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">'
+ options
+ ' </select>'
+ '</div>';
var select = document.getElementById('serverSelect');
if (select) {
select.value = currentServerId || '';
select.addEventListener('change', function(e) {
setCurrentServer(e.target.value);
});
}
if (typeof updateTranslations === 'function') {
updateTranslations();
}
}
function renderServerSubtitle() {
var subtitle = document.getElementById('currentServerSubtitle');
if (!subtitle) return;
if (!currentServer) {
subtitle.textContent = t('servers.selector.none', 'No server configured. Please add a Fail2ban server.');
subtitle.classList.add('text-red-500');
return;
}
subtitle.classList.remove('text-red-500');
var parts = [];
parts.push(currentServer.name || currentServer.id);
parts.push(currentServer.type ? currentServer.type.toUpperCase() : 'LOCAL');
if (currentServer.host) {
var host = currentServer.host;
if (currentServer.port) {
host += ':' + currentServer.port;
}
parts.push(host);
} else if (currentServer.hostname) {
parts.push(currentServer.hostname);
}
subtitle.textContent = parts.join(' • ');
}
function setCurrentServer(serverId) {
if (!serverId) {
currentServerId = null;
currentServer = null;
} else {
var next = serversCache.find(function(s) { return s.id === serverId && s.enabled; });
currentServer = next || null;
currentServerId = currentServer ? currentServer.id : null;
}
renderServerSelector();
renderServerSubtitle();
updateRestartBanner();
refreshData();
}
function openServerManager(serverId) {
showLoading(true);
loadServers()
.then(function() {
if (serverId) {
editServer(serverId);
} else {
resetServerForm();
}
renderServerManagerList();
openModal('serverManagerModal');
})
.finally(function() {
showLoading(false);
});
}
function renderServerManagerList() {
var list = document.getElementById('serverManagerList');
var emptyState = document.getElementById('serverManagerListEmpty');
if (!list || !emptyState) return;
if (!serversCache.length) {
list.innerHTML = '';
emptyState.classList.remove('hidden');
if (typeof updateTranslations === 'function') updateTranslations();
return;
}
emptyState.classList.add('hidden');
var html = serversCache.map(function(server) {
var statusBadge = server.enabled
? '<span class="ml-2 text-xs font-semibold text-green-600" data-i18n="servers.badge.enabled">Enabled</span>'
: '<span class="ml-2 text-xs font-semibold text-gray-500" data-i18n="servers.badge.disabled">Disabled</span>';
var defaultBadge = server.isDefault
? '<span class="ml-2 text-xs font-semibold text-blue-600" data-i18n="servers.badge.default">Default</span>'
: '';
var descriptor = [];
if (server.type) {
descriptor.push(server.type.toUpperCase());
}
if (server.host) {
var endpoint = server.host;
if (server.port) {
endpoint += ':' + server.port;
}
descriptor.push(endpoint);
} else if (server.hostname) {
descriptor.push(server.hostname);
}
var meta = descriptor.join(' • ');
var tags = (server.tags || []).length
? '<div class="mt-2 text-xs text-gray-500">' + escapeHtml(server.tags.join(', ')) + '</div>'
: '';
return ''
+ '<div class="border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50">'
+ ' <div class="flex items-center justify-between">'
+ ' <div>'
+ ' <p class="font-semibold text-gray-800 flex items-center">' + escapeHtml(server.name || server.id) + defaultBadge + statusBadge + '</p>'
+ ' <p class="text-sm text-gray-500">' + escapeHtml(meta || server.id) + '</p>'
+ tags
+ ' </div>'
+ ' <div class="flex flex-col gap-2">'
+ ' <button class="text-sm text-blue-600 hover:text-blue-800" onclick="editServer(\'' + escapeHtml(server.id) + '\')" data-i18n="servers.actions.edit">Edit</button>'
+ (server.isDefault ? '' : '<button class="text-sm text-blue-600 hover:text-blue-800" onclick="makeDefaultServer(\'' + escapeHtml(server.id) + '\')" data-i18n="servers.actions.set_default">Set default</button>')
+ ' <button class="text-sm text-blue-600 hover:text-blue-800" onclick="setServerEnabled(\'' + escapeHtml(server.id) + '\',' + (server.enabled ? 'false' : 'true') + ')" data-i18n="' + (server.enabled ? 'servers.actions.disable' : 'servers.actions.enable') + '">' + (server.enabled ? 'Disable' : 'Enable') + '</button>'
+ (server.enabled ? '<button class="text-sm text-blue-600 hover:text-blue-800" onclick="restartFail2banServer(\'' + escapeHtml(server.id) + '\')" data-i18n="servers.actions.restart">Restart Fail2ban</button>' : '')
+ ' <button class="text-sm text-blue-600 hover:text-blue-800" onclick="testServerConnection(\'' + escapeHtml(server.id) + '\')" data-i18n="servers.actions.test">Test connection</button>'
+ ' <button class="text-sm text-red-600 hover:text-red-800" onclick="deleteServer(\'' + escapeHtml(server.id) + '\')" data-i18n="servers.actions.delete">Delete</button>'
+ ' </div>'
+ ' </div>'
+ '</div>';
}).join('');
list.innerHTML = html;
if (typeof updateTranslations === 'function') updateTranslations();
}
function resetServerForm() {
document.getElementById('serverId').value = '';
document.getElementById('serverName').value = '';
document.getElementById('serverType').value = 'local';
document.getElementById('serverHost').value = '';
document.getElementById('serverPort').value = '';
document.getElementById('serverSocket').value = '/var/run/fail2ban/fail2ban.sock';
document.getElementById('serverLogPath').value = '/var/log/fail2ban.log';
document.getElementById('serverHostname').value = '';
document.getElementById('serverSSHUser').value = '';
document.getElementById('serverSSHKey').value = '';
document.getElementById('serverAgentUrl').value = '';
document.getElementById('serverAgentSecret').value = '';
document.getElementById('serverTags').value = '';
document.getElementById('serverDefault').checked = false;
document.getElementById('serverEnabled').checked = false;
populateSSHKeySelect(sshKeysCache || [], '');
onServerTypeChange('local');
}
function editServer(serverId) {
var server = serversCache.find(function(s) { return s.id === serverId; });
if (!server) return;
document.getElementById('serverId').value = server.id || '';
document.getElementById('serverName').value = server.name || '';
document.getElementById('serverType').value = server.type || 'local';
document.getElementById('serverHost').value = server.host || '';
document.getElementById('serverPort').value = server.port || '';
document.getElementById('serverSocket').value = server.socketPath || '/var/run/fail2ban/fail2ban.sock';
document.getElementById('serverLogPath').value = server.logPath || '/var/log/fail2ban.log';
document.getElementById('serverHostname').value = server.hostname || '';
document.getElementById('serverSSHUser').value = server.sshUser || '';
document.getElementById('serverSSHKey').value = server.sshKeyPath || '';
document.getElementById('serverAgentUrl').value = server.agentUrl || '';
document.getElementById('serverAgentSecret').value = server.agentSecret || '';
document.getElementById('serverTags').value = (server.tags || []).join(',');
document.getElementById('serverDefault').checked = !!server.isDefault;
document.getElementById('serverEnabled').checked = !!server.enabled;
onServerTypeChange(server.type || 'local');
if ((server.type || 'local') === 'ssh') {
loadSSHKeys().then(function(keys) {
populateSSHKeySelect(keys, server.sshKeyPath || '');
});
}
}
function onServerTypeChange(type) {
document.querySelectorAll('[data-server-fields]').forEach(function(el) {
var values = (el.getAttribute('data-server-fields') || '').split(/\s+/);
if (values.indexOf(type) !== -1) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
var enabledToggle = document.getElementById('serverEnabled');
if (!enabledToggle) return;
var isEditing = !!document.getElementById('serverId').value;
if (isEditing) {
return;
}
if (type === 'local') {
enabledToggle.checked = false;
} else {
enabledToggle.checked = true;
}
if (type === 'ssh') {
loadSSHKeys().then(function(keys) {
if (!isEditing) {
populateSSHKeySelect(keys, '');
}
});
} else {
populateSSHKeySelect([], '');
}
}
function submitServerForm(event) {
event.preventDefault();
showLoading(true);
var payload = {
id: document.getElementById('serverId').value || undefined,
name: document.getElementById('serverName').value.trim(),
type: document.getElementById('serverType').value,
host: document.getElementById('serverHost').value.trim(),
port: document.getElementById('serverPort').value ? parseInt(document.getElementById('serverPort').value, 10) : undefined,
socketPath: document.getElementById('serverSocket').value.trim(),
logPath: document.getElementById('serverLogPath').value.trim(),
hostname: document.getElementById('serverHostname').value.trim(),
sshUser: document.getElementById('serverSSHUser').value.trim(),
sshKeyPath: document.getElementById('serverSSHKey').value.trim(),
agentUrl: document.getElementById('serverAgentUrl').value.trim(),
agentSecret: document.getElementById('serverAgentSecret').value.trim(),
tags: document.getElementById('serverTags').value
? document.getElementById('serverTags').value.split(',').map(function(tag) { return tag.trim(); }).filter(Boolean)
: [],
enabled: document.getElementById('serverEnabled').checked
};
if (!payload.socketPath) delete payload.socketPath;
if (!payload.logPath) delete payload.logPath;
if (!payload.hostname) delete payload.hostname;
if (!payload.agentUrl) delete payload.agentUrl;
if (!payload.agentSecret) delete payload.agentSecret;
if (!payload.sshUser) delete payload.sshUser;
if (!payload.sshKeyPath) delete payload.sshKeyPath;
if (document.getElementById('serverDefault').checked) {
payload.isDefault = true;
}
if (payload.type !== 'local' && payload.type !== 'ssh') {
delete payload.socketPath;
}
if (payload.type !== 'local') {
delete payload.logPath;
}
if (payload.type !== 'ssh') {
delete payload.sshUser;
delete payload.sshKeyPath;
}
if (payload.type !== 'agent') {
delete payload.agentUrl;
delete payload.agentSecret;
}
fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast('Error saving server: ' + (data.error || 'Unknown error'), 'error');
return;
}
showToast(t('servers.form.success', 'Server saved successfully.'), 'success');
var saved = data.server || {};
currentServerId = saved.id || currentServerId;
return loadServers().then(function() {
renderServerManagerList();
renderServerSelector();
renderServerSubtitle();
if (currentServerId) {
currentServer = serversCache.find(function(s) { return s.id === currentServerId; }) || currentServer;
}
return refreshData({ silent: true });
});
})
.catch(function(err) {
showToast('Error saving server: ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function populateSSHKeySelect(keys, selected) {
var select = document.getElementById('serverSSHKeySelect');
if (!select) return;
var options = '<option value="" data-i18n="servers.form.select_key_placeholder">Manual entry</option>';
var selectedInList = false;
if (keys && keys.length) {
keys.forEach(function(key) {
var safe = escapeHtml(key);
if (selected && key === selected) {
selectedInList = true;
}
options += '<option value="' + safe + '">' + safe + '</option>';
});
} else {
options += '<option value="" disabled data-i18n="servers.form.no_keys">No SSH keys found; enter path manually</option>';
}
if (selected && !selectedInList) {
var safeSelected = escapeHtml(selected);
options += '<option value="' + safeSelected + '">' + safeSelected + '</option>';
}
select.innerHTML = options;
if (selected) {
select.value = selected;
} else {
select.value = '';
}
if (typeof updateTranslations === 'function') {
updateTranslations();
}
}
function loadSSHKeys() {
if (sshKeysCache !== null) {
populateSSHKeySelect(sshKeysCache, document.getElementById('serverSSHKey').value);
return Promise.resolve(sshKeysCache);
}
return fetch('/api/ssh/keys')
.then(function(res) { return res.json(); })
.then(function(data) {
sshKeysCache = data.keys || [];
populateSSHKeySelect(sshKeysCache, document.getElementById('serverSSHKey').value);
return sshKeysCache;
})
.catch(function(err) {
console.error('Error loading SSH keys:', err);
sshKeysCache = [];
populateSSHKeySelect(sshKeysCache, document.getElementById('serverSSHKey').value);
return sshKeysCache;
});
}
function setServerEnabled(serverId, enabled) {
var server = serversCache.find(function(s) { return s.id === serverId; });
if (!server) {
return;
}
var payload = Object.assign({}, server, { enabled: enabled });
showLoading(true);
fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast('Error saving server: ' + (data.error || 'Unknown error'), 'error');
return;
}
if (!enabled && currentServerId === serverId) {
currentServerId = null;
currentServer = null;
}
return loadServers().then(function() {
renderServerManagerList();
renderServerSelector();
renderServerSubtitle();
return refreshData({ silent: true });
});
})
.catch(function(err) {
showToast('Error saving server: ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function testServerConnection(serverId) {
if (!serverId) return;
showLoading(true);
fetch('/api/servers/' + encodeURIComponent(serverId) + '/test', {
method: 'POST'
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast(t(data.messageKey || 'servers.actions.test_failure', data.error), 'error');
return;
}
showToast(t(data.messageKey || 'servers.actions.test_success', data.message || 'Connection successful'), 'success');
})
.catch(function(err) {
showToast(t('servers.actions.test_failure', 'Connection failed') + ': ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function deleteServer(serverId) {
if (!confirm(t('servers.actions.delete_confirm', 'Delete this server entry?'))) return;
showLoading(true);
fetch('/api/servers/' + encodeURIComponent(serverId), { method: 'DELETE' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast('Error deleting server: ' + (data.error || 'Unknown error'), 'error');
return;
}
if (currentServerId === serverId) {
currentServerId = null;
currentServer = null;
}
return loadServers().then(function() {
renderServerManagerList();
renderServerSelector();
renderServerSubtitle();
return refreshData({ silent: true });
}).then(function() {
showToast(t('servers.actions.delete_success', 'Server removed'), 'success');
});
})
.catch(function(err) {
showToast('Error deleting server: ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function makeDefaultServer(serverId) {
showLoading(true);
fetch('/api/servers/' + encodeURIComponent(serverId) + '/default', { method: 'POST' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast('Error setting default server: ' + (data.error || 'Unknown error'), 'error');
return;
}
currentServerId = data.server ? data.server.id : serverId;
return loadServers().then(function() {
renderServerManagerList();
renderServerSelector();
renderServerSubtitle();
return refreshData({ silent: true });
}).then(function() {
showToast(t('servers.actions.set_default_success', 'Server set as default'), 'success');
});
})
.catch(function(err) {
showToast('Error setting default server: ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function restartFail2banServer(serverId) {
if (!serverId) {
showToast("No server selected", 'error');
return;
}
if (!confirm("Keep in mind that while fail2ban is restarting, logs are not being parsed and no IP addresses are blocked. Restart fail2ban on this server now? This will take some time.")) return;
showLoading(true);
fetch('/api/fail2ban/restart?serverId=' + encodeURIComponent(serverId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast("Failed to restart Fail2ban: " + data.error, 'error');
return;
}
return loadServers().then(function() {
updateRestartBanner();
showToast(t('restart_banner.success', 'Fail2ban restart triggered'), 'success');
return refreshData({ silent: true });
});
})
.catch(function(err) {
showToast("Failed to restart Fail2ban: " + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function restartFail2ban() {
if (!confirm("Keep in mind that while fail2ban is restarting, logs are not being parsed and no IP addresses are blocked. Restart fail2ban now? This will take some time.")) return;
restartFail2banServer(currentServerId);
}

View File

@@ -0,0 +1,406 @@
// Settings page functions for Fail2ban UI
"use strict";
function loadSettings() {
showLoading(true);
fetch('/api/settings')
.then(res => res.json())
.then(data => {
document.getElementById('languageSelect').value = data.language || 'en';
// Handle PORT environment variable
const uiPortInput = document.getElementById('uiPort');
const portEnvHint = document.getElementById('portEnvHint');
const portEnvValue = document.getElementById('portEnvValue');
const portRestartHint = document.getElementById('portRestartHint');
if (data.portEnvSet) {
// PORT env is set - make field readonly and show hint
uiPortInput.value = data.port || data.portFromEnv || 8080;
uiPortInput.readOnly = true;
uiPortInput.classList.add('bg-gray-100', 'cursor-not-allowed');
portEnvValue.textContent = data.portFromEnv || data.port || 8080;
portEnvHint.style.display = 'block';
portRestartHint.style.display = 'none';
} else {
// PORT env not set - allow editing
uiPortInput.value = data.port || 8080;
uiPortInput.readOnly = false;
uiPortInput.classList.remove('bg-gray-100', 'cursor-not-allowed');
portEnvHint.style.display = 'none';
portRestartHint.style.display = 'block';
}
document.getElementById('debugMode').checked = data.debug || false;
// Set callback URL and add auto-update listener for port changes
const callbackURLInput = document.getElementById('callbackURL');
callbackURLInput.value = data.callbackUrl || '';
// Auto-update callback URL when port changes (if using default localhost pattern)
function updateCallbackURLIfDefault() {
const currentPort = parseInt(uiPortInput.value, 10) || 8080;
const currentCallbackURL = callbackURLInput.value.trim();
// Check if callback URL matches default localhost pattern
const defaultPattern = /^http:\/\/127\.0\.0\.1:\d+$/;
if (currentCallbackURL === '' || defaultPattern.test(currentCallbackURL)) {
callbackURLInput.value = 'http://127.0.0.1:' + currentPort;
}
}
// Add listener to port input to auto-update callback URL
uiPortInput.addEventListener('input', updateCallbackURLIfDefault);
document.getElementById('destEmail').value = data.destemail || '';
const select = document.getElementById('alertCountries');
for (let i = 0; i < select.options.length; i++) {
select.options[i].selected = false;
}
if (!data.alertCountries || data.alertCountries.length === 0) {
select.options[0].selected = true;
} else {
for (let i = 0; i < select.options.length; i++) {
let val = select.options[i].value;
if (data.alertCountries.includes(val)) {
select.options[i].selected = true;
}
}
}
$('#alertCountries').trigger('change');
// Check and apply LOTR theme
checkAndApplyLOTRTheme(data.alertCountries || []);
if (data.smtp) {
document.getElementById('smtpHost').value = data.smtp.host || '';
document.getElementById('smtpPort').value = data.smtp.port || 587;
document.getElementById('smtpUsername').value = data.smtp.username || '';
document.getElementById('smtpPassword').value = data.smtp.password || '';
document.getElementById('smtpFrom').value = data.smtp.from || '';
document.getElementById('smtpUseTLS').checked = data.smtp.useTLS || false;
}
document.getElementById('bantimeIncrement').checked = data.bantimeIncrement || false;
document.getElementById('banTime').value = data.bantime || '';
document.getElementById('findTime').value = data.findtime || '';
document.getElementById('maxRetry').value = data.maxretry || '';
// Load IgnoreIPs as array
const ignoreIPs = data.ignoreips || [];
renderIgnoreIPsTags(ignoreIPs);
// Load banaction settings
document.getElementById('banaction').value = data.banaction || 'iptables-multiport';
document.getElementById('banactionAllports').value = data.banactionAllports || 'iptables-allports';
applyAdvancedActionsSettings(data.advancedActions || {});
loadPermanentBlockLog();
})
.catch(err => {
showToast('Error loading settings: ' + err, 'error');
})
.finally(() => showLoading(false));
}
function saveSettings(event) {
event.preventDefault();
// Validate all fields before submitting
if (!validateAllSettings()) {
showToast('Please fix validation errors before saving', 'error');
return;
}
showLoading(true);
const smtpSettings = {
host: document.getElementById('smtpHost').value.trim(),
port: parseInt(document.getElementById('smtpPort').value, 10) || 587,
username: document.getElementById('smtpUsername').value.trim(),
password: document.getElementById('smtpPassword').value.trim(),
from: document.getElementById('smtpFrom').value.trim(),
useTLS: document.getElementById('smtpUseTLS').checked,
};
const selectedCountries = Array.from(document.getElementById('alertCountries').selectedOptions).map(opt => opt.value);
// Auto-update callback URL if using default localhost pattern and port changed
const callbackURLInput = document.getElementById('callbackURL');
let callbackUrl = callbackURLInput.value.trim();
const currentPort = parseInt(document.getElementById('uiPort').value, 10) || 8080;
const defaultPattern = /^http:\/\/127\.0\.0\.1:\d+$/;
if (callbackUrl === '' || defaultPattern.test(callbackUrl)) {
callbackUrl = 'http://127.0.0.1:' + currentPort;
}
const settingsData = {
language: document.getElementById('languageSelect').value,
port: currentPort,
debug: document.getElementById('debugMode').checked,
destemail: document.getElementById('destEmail').value.trim(),
callbackUrl: callbackUrl,
alertCountries: selectedCountries.length > 0 ? selectedCountries : ["ALL"],
bantimeIncrement: document.getElementById('bantimeIncrement').checked,
bantime: document.getElementById('banTime').value.trim(),
findtime: document.getElementById('findTime').value.trim(),
maxretry: parseInt(document.getElementById('maxRetry').value, 10) || 3,
ignoreips: getIgnoreIPsArray(),
banaction: document.getElementById('banaction').value,
banactionAllports: document.getElementById('banactionAllports').value,
smtp: smtpSettings,
advancedActions: collectAdvancedActionsSettings()
};
fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(settingsData),
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error saving settings: ' + (data.error + (data.details || '')), 'error');
} else {
var selectedLang = $('#languageSelect').val();
loadTranslations(selectedLang);
console.log("Settings saved successfully. Restart needed? " + (data.restartNeeded || false));
// Check and apply LOTR theme after saving
const selectedCountries = Array.from(document.getElementById('alertCountries').selectedOptions).map(opt => opt.value);
checkAndApplyLOTRTheme(selectedCountries.length > 0 ? selectedCountries : ["ALL"]);
if (data.restartNeeded) {
showToast(t('settings.save_success', 'Settings saved. Fail2ban restart required.'), 'info');
loadServers().then(function() {
updateRestartBanner();
});
} else {
showToast(t('settings.save_success', 'Settings saved and fail2ban reloaded'), 'success');
}
}
})
.catch(err => showToast('Error saving settings: ' + err, 'error'))
.finally(() => showLoading(false));
}
function sendTestEmail() {
showLoading(true);
fetch('/api/settings/test-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error sending test email: ' + data.error, 'error');
} else {
showToast('Test email sent successfully!', 'success');
}
})
.catch(error => showToast('Error sending test email: ' + error, 'error'))
.finally(() => showLoading(false));
}
function applyAdvancedActionsSettings(cfg) {
cfg = cfg || {};
document.getElementById('advancedActionsEnabled').checked = !!cfg.enabled;
document.getElementById('advancedThreshold').value = cfg.threshold || 5;
const integrationSelect = document.getElementById('advancedIntegrationSelect');
integrationSelect.value = cfg.integration || '';
const mk = cfg.mikrotik || {};
document.getElementById('mikrotikHost').value = mk.host || '';
document.getElementById('mikrotikPort').value = mk.port || 22;
document.getElementById('mikrotikUsername').value = mk.username || '';
document.getElementById('mikrotikPassword').value = mk.password || '';
document.getElementById('mikrotikSSHKey').value = mk.sshKeyPath || '';
document.getElementById('mikrotikList').value = mk.addressList || 'fail2ban-permanent';
const pf = cfg.pfSense || {};
document.getElementById('pfSenseBaseURL').value = pf.baseUrl || '';
document.getElementById('pfSenseToken').value = pf.apiToken || '';
document.getElementById('pfSenseSecret').value = pf.apiSecret || '';
document.getElementById('pfSenseAlias').value = pf.alias || '';
document.getElementById('pfSenseSkipTLS').checked = !!pf.skipTLSVerify;
updateAdvancedIntegrationFields();
}
function collectAdvancedActionsSettings() {
return {
enabled: document.getElementById('advancedActionsEnabled').checked,
threshold: parseInt(document.getElementById('advancedThreshold').value, 10) || 5,
integration: document.getElementById('advancedIntegrationSelect').value,
mikrotik: {
host: document.getElementById('mikrotikHost').value.trim(),
port: parseInt(document.getElementById('mikrotikPort').value, 10) || 22,
username: document.getElementById('mikrotikUsername').value.trim(),
password: document.getElementById('mikrotikPassword').value,
sshKeyPath: document.getElementById('mikrotikSSHKey').value.trim(),
addressList: document.getElementById('mikrotikList').value.trim() || 'fail2ban-permanent',
},
pfSense: {
baseUrl: document.getElementById('pfSenseBaseURL').value.trim(),
apiToken: document.getElementById('pfSenseToken').value.trim(),
apiSecret: document.getElementById('pfSenseSecret').value.trim(),
alias: document.getElementById('pfSenseAlias').value.trim(),
skipTLSVerify: document.getElementById('pfSenseSkipTLS').checked,
}
};
}
function updateAdvancedIntegrationFields() {
const selected = document.getElementById('advancedIntegrationSelect').value;
document.getElementById('advancedMikrotikFields').classList.toggle('hidden', selected !== 'mikrotik');
document.getElementById('advancedPfSenseFields').classList.toggle('hidden', selected !== 'pfsense');
}
function loadPermanentBlockLog() {
fetch('/api/advanced-actions/blocks')
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error loading permanent block log: ' + data.error, 'error');
return;
}
renderPermanentBlockLog(data.blocks || []);
})
.catch(err => {
showToast('Error loading permanent block log: ' + err, 'error');
});
}
function renderPermanentBlockLog(blocks) {
const container = document.getElementById('permanentBlockLog');
if (!container) return;
if (!blocks.length) {
container.innerHTML = '<p class="text-sm text-gray-500 p-4" data-i18n="settings.advanced.log_empty">No permanent blocks recorded yet.</p>';
if (typeof updateTranslations === 'function') updateTranslations();
return;
}
let rows = blocks.map(block => {
const statusClass = block.status === 'blocked'
? 'text-green-600'
: (block.status === 'unblocked' ? 'text-gray-500' : 'text-red-600');
const message = block.message ? escapeHtml(block.message) : '';
return ''
+ '<tr class="border-t">'
+ ' <td class="px-3 py-2 font-mono text-sm">' + escapeHtml(block.ip) + '</td>'
+ ' <td class="px-3 py-2 text-sm">' + escapeHtml(block.integration) + '</td>'
+ ' <td class="px-3 py-2 text-sm ' + statusClass + '">' + escapeHtml(block.status) + '</td>'
+ ' <td class="px-3 py-2 text-sm">' + (message || '&nbsp;') + '</td>'
+ ' <td class="px-3 py-2 text-xs text-gray-500">' + escapeHtml(block.serverId || '') + '</td>'
+ ' <td class="px-3 py-2 text-xs text-gray-500">' + (block.updatedAt ? new Date(block.updatedAt).toLocaleString() : '') + '</td>'
+ ' <td class="px-3 py-2 text-right">'
+ ' <button type="button" class="text-sm text-blue-600 hover:text-blue-800" onclick="advancedUnblockIP(\'' + escapeHtml(block.ip) + '\', event)" data-i18n="settings.advanced.unblock_btn">Remove</button>'
+ ' </td>'
+ '</tr>';
}).join('');
container.innerHTML = ''
+ '<table class="min-w-full text-sm">'
+ ' <thead class="bg-gray-50 text-left">'
+ ' <tr>'
+ ' <th class="px-3 py-2" data-i18n="settings.advanced.log_ip">IP</th>'
+ ' <th class="px-3 py-2" data-i18n="settings.advanced.log_integration">Integration</th>'
+ ' <th class="px-3 py-2" data-i18n="settings.advanced.log_status">Status</th>'
+ ' <th class="px-3 py-2" data-i18n="settings.advanced.log_message">Message</th>'
+ ' <th class="px-3 py-2" data-i18n="settings.advanced.log_server">Server</th>'
+ ' <th class="px-3 py-2" data-i18n="settings.advanced.log_updated">Updated</th>'
+ ' <th class="px-3 py-2 text-right" data-i18n="settings.advanced.log_actions">Actions</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody>' + rows + '</tbody>'
+ '</table>';
if (typeof updateTranslations === 'function') updateTranslations();
}
function refreshPermanentBlockLog() {
loadPermanentBlockLog();
}
function openAdvancedTestModal() {
populateAdvancedTestServers();
document.getElementById('advancedTestIP').value = '';
document.getElementById('advancedTestServer').value = '';
openModal('advancedTestModal');
}
function populateAdvancedTestServers() {
const select = document.getElementById('advancedTestServer');
if (!select) return;
const value = select.value;
select.innerHTML = '';
const baseOption = document.createElement('option');
baseOption.value = '';
baseOption.textContent = t('settings.advanced.test_server_none', 'Use global integration settings');
select.appendChild(baseOption);
serversCache.forEach(server => {
const opt = document.createElement('option');
opt.value = server.id;
opt.textContent = server.name || server.id;
select.appendChild(opt);
});
select.value = value;
}
function submitAdvancedTest(action) {
const ipValue = document.getElementById('advancedTestIP').value.trim();
if (!ipValue) {
showToast('Please enter an IP address.', 'info');
return;
}
const serverId = document.getElementById('advancedTestServer').value;
showLoading(true);
fetch('/api/advanced-actions/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action, ip: ipValue, serverId: serverId })
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Advanced action failed: ' + data.error, 'error');
} else {
// Check if this is an info message (e.g., IP already blocked)
const toastType = data.info ? 'info' : 'success';
showToast(data.message || 'Action completed', toastType);
loadPermanentBlockLog();
}
})
.catch(err => showToast('Advanced action failed: ' + err, 'error'))
.finally(() => {
showLoading(false);
closeModal('advancedTestModal');
});
}
function advancedUnblockIP(ip, event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
if (!ip) return;
fetch('/api/advanced-actions/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'unblock', ip: ip })
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Failed to remove IP: ' + data.error, 'error');
} else {
showToast(data.message || 'IP removed', 'success');
loadPermanentBlockLog();
}
})
.catch(err => showToast('Failed to remove IP: ' + err, 'error'));
}
// Initialize advanced integration select listener
const advancedIntegrationSelect = document.getElementById('advancedIntegrationSelect');
if (advancedIntegrationSelect) {
advancedIntegrationSelect.addEventListener('change', updateAdvancedIntegrationFields);
}

View File

@@ -0,0 +1,46 @@
// Translation functions for Fail2ban UI
"use strict";
// Loads translation JSON file for given language (e.g., en, de, etc.)
function loadTranslations(lang) {
$.getJSON('/locales/' + lang + '.json')
.done(function(data) {
translations = data;
updateTranslations();
})
.fail(function() {
console.error('Failed to load translations for language:', lang);
});
}
// Updates all elements with data-i18n attribute with corresponding translation.
function updateTranslations() {
$('[data-i18n]').each(function() {
var key = $(this).data('i18n');
if (translations[key]) {
$(this).text(translations[key]);
}
});
// Updates placeholders.
$('[data-i18n-placeholder]').each(function() {
var key = $(this).data('i18n-placeholder');
if (translations[key]) {
$(this).attr('placeholder', translations[key]);
}
});
}
function getTranslationsSettingsOnPageload() {
return fetch('/api/settings')
.then(function(res) { return res.json(); })
.then(function(data) {
var lang = data.language || 'en';
$('#languageSelect').val(lang);
loadTranslations(lang);
})
.catch(function(err) {
console.error('Error loading initial settings:', err);
loadTranslations('en');
});
}

106
pkg/web/static/js/utils.js Normal file
View File

@@ -0,0 +1,106 @@
// Utility functions for Fail2ban UI
"use strict";
function normalizeInsights(data) {
var normalized = data && typeof data === 'object' ? data : {};
if (!normalized.totals || typeof normalized.totals !== 'object') {
normalized.totals = { overall: 0, today: 0, week: 0 };
} else {
normalized.totals.overall = typeof normalized.totals.overall === 'number' ? normalized.totals.overall : 0;
normalized.totals.today = typeof normalized.totals.today === 'number' ? normalized.totals.today : 0;
normalized.totals.week = typeof normalized.totals.week === 'number' ? normalized.totals.week : 0;
}
if (!Array.isArray(normalized.countries)) {
normalized.countries = [];
}
if (!Array.isArray(normalized.recurring)) {
normalized.recurring = [];
}
return normalized;
}
function t(key, fallback) {
if (translations && Object.prototype.hasOwnProperty.call(translations, key) && translations[key]) {
return translations[key];
}
return fallback !== undefined ? fallback : key;
}
function captureFocusState(container) {
var active = document.activeElement;
if (!active || !container || !container.contains(active)) {
return null;
}
var state = { id: active.id || null };
if (!state.id) {
return null;
}
try {
if (typeof active.selectionStart === 'number' && typeof active.selectionEnd === 'number') {
state.selectionStart = active.selectionStart;
state.selectionEnd = active.selectionEnd;
}
} catch (err) {
// Ignore selection errors for elements that do not support it.
}
return state;
}
function restoreFocusState(state) {
if (!state || !state.id) {
return;
}
var next = document.getElementById(state.id);
if (!next) {
return;
}
if (typeof next.focus === 'function') {
try {
next.focus({ preventScroll: true });
} catch (err) {
next.focus();
}
}
try {
if (typeof state.selectionStart === 'number' && typeof state.selectionEnd === 'number' && typeof next.setSelectionRange === 'function') {
next.setSelectionRange(state.selectionStart, state.selectionEnd);
}
} catch (err) {
// Element may not support setSelectionRange; ignore.
}
}
function highlightQueryMatch(value, query) {
var text = value || '';
if (!query) {
return escapeHtml(text);
}
var escapedPattern = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (!escapedPattern) {
return escapeHtml(text);
}
var regex = new RegExp(escapedPattern, "gi");
var highlighted = text.replace(regex, function(match) {
return "%%MARK_START%%" + match + "%%MARK_END%%";
});
return escapeHtml(highlighted)
.replace(/%%MARK_START%%/g, "<mark>")
.replace(/%%MARK_END%%/g, "</mark>");
}
function slugifyId(value, prefix) {
var input = (value || '').toString();
var base = input.toLowerCase().replace(/[^a-z0-9]+/g, '-');
var hash = 0;
for (var i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash) + input.charCodeAt(i);
hash |= 0;
}
hash = Math.abs(hash);
base = base.replace(/^-+|-+$/g, '');
if (!base) {
base = 'item';
}
return (prefix || 'id') + '-' + base + '-' + hash;
}

View File

@@ -0,0 +1,267 @@
// Validation functions for Fail2ban UI
function validateTimeFormat(value, fieldName) {
if (!value || !value.trim()) return { valid: true }; // Empty is OK
const timePattern = /^\d+[smhdwmy]$/i;
if (!timePattern.test(value.trim())) {
return {
valid: false,
message: 'Invalid time format. Use format like: 1h, 30m, 2d, 1w, 1m, 1y'
};
}
return { valid: true };
}
function validateMaxRetry(value) {
if (!value || value.trim() === '') return { valid: true }; // Empty is OK
const num = parseInt(value, 10);
if (isNaN(num) || num < 1) {
return {
valid: false,
message: 'Max retry must be a positive integer (minimum 1)'
};
}
return { valid: true };
}
function validateEmail(value) {
if (!value || !value.trim()) return { valid: true }; // Empty is OK
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(value.trim())) {
return {
valid: false,
message: 'Invalid email format'
};
}
return { valid: true };
}
// Validate IP address (IPv4, IPv6, CIDR, or hostname)
function isValidIP(ip) {
if (!ip || !ip.trim()) return false;
ip = ip.trim();
// Allow hostnames (fail2ban supports DNS hostnames)
// Basic hostname validation: alphanumeric, dots, hyphens
const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)*$/;
// IPv4 with optional CIDR
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
// IPv6 with optional CIDR (simplified - allows various IPv6 formats)
const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/;
const ipv6CompressedPattern = /^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/;
const ipv6FullPattern = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}(\/\d{1,3})?$/;
// Check IPv4
if (ipv4Pattern.test(ip)) {
const parts = ip.split('/');
const octets = parts[0].split('.');
for (let octet of octets) {
const num = parseInt(octet, 10);
if (num < 0 || num > 255) return false;
}
if (parts.length > 1) {
const cidr = parseInt(parts[1], 10);
if (cidr < 0 || cidr > 32) return false;
}
return true;
}
// Check IPv6
if (ipv6Pattern.test(ip) || ipv6CompressedPattern.test(ip) || ipv6FullPattern.test(ip)) {
if (ip.includes('/')) {
const parts = ip.split('/');
const cidr = parseInt(parts[1], 10);
if (cidr < 0 || cidr > 128) return false;
}
return true;
}
// Check hostname
if (hostnamePattern.test(ip)) {
return true;
}
return false;
}
function validateIgnoreIPs() {
if (typeof getIgnoreIPsArray !== 'function') {
console.error('getIgnoreIPsArray function not found');
return { valid: true }; // Skip validation if function not available
}
const ignoreIPs = getIgnoreIPsArray();
const invalidIPs = [];
for (let i = 0; i < ignoreIPs.length; i++) {
const ip = ignoreIPs[i];
if (!isValidIP(ip)) {
invalidIPs.push(ip);
}
}
if (invalidIPs.length > 0) {
return {
valid: false,
message: 'Invalid IP addresses, CIDR notation, or hostnames: ' + invalidIPs.join(', ')
};
}
return { valid: true };
}
function showFieldError(fieldId, message) {
const errorElement = document.getElementById(fieldId + 'Error');
const inputElement = document.getElementById(fieldId);
if (errorElement) {
errorElement.textContent = message;
errorElement.classList.remove('hidden');
}
if (inputElement) {
inputElement.classList.add('border-red-500');
inputElement.classList.remove('border-gray-300');
}
}
function clearFieldError(fieldId) {
const errorElement = document.getElementById(fieldId + 'Error');
const inputElement = document.getElementById(fieldId);
if (errorElement) {
errorElement.classList.add('hidden');
errorElement.textContent = '';
}
if (inputElement) {
inputElement.classList.remove('border-red-500');
inputElement.classList.add('border-gray-300');
}
}
function validateAllSettings() {
let isValid = true;
// Validate bantime
const banTime = document.getElementById('banTime');
if (banTime) {
const banTimeValidation = validateTimeFormat(banTime.value, 'bantime');
if (!banTimeValidation.valid) {
showFieldError('banTime', banTimeValidation.message);
isValid = false;
} else {
clearFieldError('banTime');
}
}
// Validate findtime
const findTime = document.getElementById('findTime');
if (findTime) {
const findTimeValidation = validateTimeFormat(findTime.value, 'findtime');
if (!findTimeValidation.valid) {
showFieldError('findTime', findTimeValidation.message);
isValid = false;
} else {
clearFieldError('findTime');
}
}
// Validate max retry
const maxRetry = document.getElementById('maxRetry');
if (maxRetry) {
const maxRetryValidation = validateMaxRetry(maxRetry.value);
if (!maxRetryValidation.valid) {
showFieldError('maxRetry', maxRetryValidation.message);
isValid = false;
} else {
clearFieldError('maxRetry');
}
}
// Validate email
const destEmail = document.getElementById('destEmail');
if (destEmail) {
const emailValidation = validateEmail(destEmail.value);
if (!emailValidation.valid) {
showFieldError('destEmail', emailValidation.message);
isValid = false;
} else {
clearFieldError('destEmail');
}
}
// Validate IgnoreIPs
const ignoreIPsValidation = validateIgnoreIPs();
if (!ignoreIPsValidation.valid) {
// Show error for ignoreIPs field
const errorContainer = document.getElementById('ignoreIPsError');
if (errorContainer) {
errorContainer.textContent = ignoreIPsValidation.message;
errorContainer.classList.remove('hidden');
}
if (typeof showToast === 'function') {
showToast(ignoreIPsValidation.message, 'error');
}
isValid = false;
} else {
const errorContainer = document.getElementById('ignoreIPsError');
if (errorContainer) {
errorContainer.classList.add('hidden');
errorContainer.textContent = '';
}
}
return isValid;
}
// Setup validation on blur for all fields
function setupFormValidation() {
const banTimeInput = document.getElementById('banTime');
const findTimeInput = document.getElementById('findTime');
const maxRetryInput = document.getElementById('maxRetry');
const destEmailInput = document.getElementById('destEmail');
if (banTimeInput) {
banTimeInput.addEventListener('blur', function() {
const validation = validateTimeFormat(this.value, 'bantime');
if (!validation.valid) {
showFieldError('banTime', validation.message);
} else {
clearFieldError('banTime');
}
});
}
if (findTimeInput) {
findTimeInput.addEventListener('blur', function() {
const validation = validateTimeFormat(this.value, 'findtime');
if (!validation.valid) {
showFieldError('findTime', validation.message);
} else {
clearFieldError('findTime');
}
});
}
if (maxRetryInput) {
maxRetryInput.addEventListener('blur', function() {
const validation = validateMaxRetry(this.value);
if (!validation.valid) {
showFieldError('maxRetry', validation.message);
} else {
clearFieldError('maxRetry');
}
});
}
if (destEmailInput) {
destEmailInput.addEventListener('blur', function() {
const validation = validateEmail(this.value);
if (!validation.valid) {
showFieldError('destEmail', validation.message);
} else {
clearFieldError('destEmail');
}
});
}
}

File diff suppressed because it is too large Load Diff