mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
Reorganize the whole javascript part as seperate files, for better maintainabillity
This commit is contained in:
@@ -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
19
pkg/web/static/js/api.js
Normal 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
201
pkg/web/static/js/core.js
Normal 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 '&';
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '"': return '"';
|
||||
default: return ''';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
715
pkg/web/static/js/dashboard.js
Normal file
715
pkg/web/static/js/dashboard.js
Normal 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;
|
||||
}
|
||||
123
pkg/web/static/js/filters.js
Normal file
123
pkg/web/static/js/filters.js
Normal 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 = '';
|
||||
}
|
||||
|
||||
24
pkg/web/static/js/globals.js
Normal file
24
pkg/web/static/js/globals.js
Normal 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;
|
||||
105
pkg/web/static/js/ignoreips.js
Normal file
105
pkg/web/static/js/ignoreips.js
Normal 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, '"') + '"]');
|
||||
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
117
pkg/web/static/js/init.js
Normal 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
327
pkg/web/static/js/jails.js
Normal 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
154
pkg/web/static/js/lotr.js
Normal 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
235
pkg/web/static/js/modals.js
Normal 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');
|
||||
}
|
||||
|
||||
561
pkg/web/static/js/servers.js
Normal file
561
pkg/web/static/js/servers.js
Normal 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);
|
||||
}
|
||||
|
||||
406
pkg/web/static/js/settings.js
Normal file
406
pkg/web/static/js/settings.js
Normal 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 || ' ') + '</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);
|
||||
}
|
||||
|
||||
46
pkg/web/static/js/translations.js
Normal file
46
pkg/web/static/js/translations.js
Normal 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
106
pkg/web/static/js/utils.js
Normal 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;
|
||||
}
|
||||
|
||||
267
pkg/web/static/js/validation.js
Normal file
267
pkg/web/static/js/validation.js
Normal 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
Reference in New Issue
Block a user