diff --git a/pkg/web/static/js/dashboard.js b/pkg/web/static/js/dashboard.js index a77a6c1..892cb7d 100644 --- a/pkg/web/static/js/dashboard.js +++ b/pkg/web/static/js/dashboard.js @@ -1,10 +1,13 @@ -// Dashboard rendering and data fetching functions for Fail2ban UI "use strict"; +// Dashboard data fetching and rendering. + +// ========================================================================= +// Data Fetching +// ========================================================================= function refreshData(options) { options = options || {}; var enabledServers = serversCache.filter(function(s) { return s.enabled; }); - var summaryPromise; if (!serversCache.length || !enabledServers.length || !currentServerId) { latestSummary = null; @@ -13,11 +16,9 @@ function refreshData(options) { } else { summaryPromise = fetchSummaryData(); } - if (!options.silent) { showLoading(true); } - return Promise.all([ summaryPromise, fetchBanStatisticsData(), @@ -39,6 +40,18 @@ function refreshData(options) { }); } +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 fetchSummaryData() { return fetch(withServerParam('/api/summary')) .then(function(res) { return res.json(); }) @@ -60,39 +73,38 @@ function fetchSummaryData() { }); } -function fetchBanStatisticsData() { - return fetch('/api/events/bans/stats') +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) { - latestBanStats = data && data.counts ? data.counts : {}; + latestBanInsights = normalizeInsights(data); }) .catch(function(err) { - console.error('Error fetching ban statistics:', err); - latestBanStats = latestBanStats || {}; + console.error('Error fetching ban insights:', err); + if (!latestBanInsights) { + latestBanInsights = normalizeInsights(null); + } }); -} - -// Builds query string for ban events API: limit, offset, search, country, serverId -function buildBanEventsQuery(offset, append) { - var params = [ - 'limit=' + BAN_EVENTS_PAGE_SIZE, - 'offset=' + (append ? Math.min(latestBanEvents.length, BAN_EVENTS_MAX_LOADED) : 0) - ]; - var search = (banEventsFilterText || '').trim(); - if (search) { - params.push('search=' + encodeURIComponent(search)); - } - var country = (banEventsFilterCountry || 'all').trim(); - if (country && country !== 'all') { - params.push('country=' + encodeURIComponent(country)); - } + var serverPromise; if (currentServerId) { - params.push('serverId=' + encodeURIComponent(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 '/api/events/bans?' + params.join('&'); + return Promise.all([globalPromise, serverPromise]); } -// options: { append: true } to load next page and append; otherwise fetches first page (reset). function fetchBanEventsData(options) { options = options || {}; var append = options.append === true; @@ -128,381 +140,13 @@ function fetchBanEventsData(options) { }); } -// Add new ban or unban event from WebSocket (only when not searching; cap at BAN_EVENTS_MAX_LOADED) -function addBanEventFromWebSocket(event) { - var hasSearch = (banEventsFilterText || '').trim().length > 0; - if (hasSearch) { - // When user is searching, list is from API; don't prepend to avoid inconsistency - if (typeof showBanEventToast === 'function') { - showBanEventToast(event); - } - refreshDashboardData(); - return; - } - var exists = false; - if (event.id) { - exists = latestBanEvents.some(function(e) { return e.id === event.id; }); - } else { - exists = latestBanEvents.some(function(e) { - return e.ip === event.ip && e.jail === event.jail && e.eventType === event.eventType && e.occurredAt === event.occurredAt; - }); - } - if (!exists) { - if (!event.eventType) { - event.eventType = 'ban'; - } - console.log('Adding new event from WebSocket:', event); - latestBanEvents.unshift(event); - if (latestBanEvents.length > BAN_EVENTS_MAX_LOADED) { - latestBanEvents = latestBanEvents.slice(0, BAN_EVENTS_MAX_LOADED); - } - if (typeof showBanEventToast === 'function') { - showBanEventToast(event); - } - refreshDashboardData(); - } else { - console.log('Skipping duplicate event:', event); - } -} - -// Refresh dashboard data when new ban event arrives via WebSocket -function refreshDashboardData() { - // Refresh ban statistics and insights in the background - // Also refresh summary if we have a server selected - var enabledServers = serversCache.filter(function(s) { return s.enabled; }); - var summaryPromise; - if (serversCache.length && enabledServers.length && currentServerId) { - summaryPromise = fetchSummaryData(); - } else { - summaryPromise = Promise.resolve(); - } - - Promise.all([ - summaryPromise, - fetchBanStatisticsData(), - fetchBanInsightsData() - ]).then(function() { - // Re-render the dashboard to show updated stats - renderDashboard(); - }).catch(function(err) { - console.error('Error refreshing dashboard data:', err); - // Still re-render even if refresh fails - renderDashboard(); - }); -} - -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]; - }); -} - -// Debounced refetch of ban events from API (search/country) and re-render only the log overview (no full dashboard = no scroll jump) -function scheduleBanEventsRefetch() { - if (banEventsFilterDebounce) { - clearTimeout(banEventsFilterDebounce); - } - banEventsFilterDebounce = setTimeout(function() { - banEventsFilterDebounce = null; - fetchBanEventsData().then(function() { - renderLogOverviewSection(); - }); - }, 300); -} - -function updateBanEventsSearch(value) { - banEventsFilterText = value || ''; - scheduleBanEventsRefetch(); -} - -function updateBanEventsCountry(value) { - banEventsFilterCountry = value || 'all'; - fetchBanEventsData().then(function() { - renderLogOverviewSection(); - }); -} - -function loadMoreBanEvents() { - if (latestBanEvents.length >= BAN_EVENTS_MAX_LOADED || !banEventsHasMore) { - return; - } - fetchBanEventsData({ append: true }).then(function() { - renderLogOverviewSection(); - }); -} - -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 'No banned IPs'; - } - 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 = '
'; - - function bannedIpRow(ip) { - var safeIp = escapeHtml(ip); - var encodedIp = encodeURIComponent(ip); - return '' - + '
' - + ' ' + safeIp + '' - + ' ' - + '
'; - } - - visible.forEach(function(ip) { - content += bannedIpRow(ip); - }); - - if (hidden.length) { - content += ''; - - var moreLabel = t('dashboard.banned.show_more', 'Show more') + ' +' + hidden.length; - var lessLabel = t('dashboard.banned.show_less', 'Hide extra'); - content += '' - + ''; - } - - content += '
'; - 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 toggleManualBlockSection() { - var container = document.getElementById('manualBlockFormContainer'); - var icon = document.getElementById('manualBlockToggleIcon'); - if (!container || !icon) { - return; - } - var isHidden = container.classList.contains("hidden"); - if (isHidden) { - container.classList.remove("hidden"); - icon.classList.remove("fa-chevron-down"); - icon.classList.add("fa-chevron-up"); - } else { - container.classList.add("hidden"); - icon.classList.remove("fa-chevron-up"); - icon.classList.add("fa-chevron-down"); - } -} - -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'); - } - // Don't show success toast here - the WebSocket unban event will show a proper toast - return refreshData({ silent: true }); - }) - .catch(function(err) { - showToast("Error: " + err, 'error'); - }) - .finally(function() { - showLoading(false); - }); -} +// ========================================================================= +// Triggers Ban / Unban Actions from the dashboard +// ========================================================================= +// Sends request to ban an IP in a jail. function banIP(jail, ip) { - const confirmMsg = isLOTRModeActive + const confirmMsg = isLOTRModeActive ? 'Banish ' + ip + ' from the realm in ' + jail + '?' : 'Block IP ' + ip + ' in jail ' + jail + '?'; if (!confirm(confirmMsg)) { @@ -531,50 +175,44 @@ function banIP(jail, ip) { }); } -function handleManualBlock() { - var jailSelect = document.getElementById('blockJailSelect'); - var ipInput = document.getElementById('blockIPInput'); - - if (!jailSelect || !ipInput) { +// Sends request to unban an IP from a jail. +function unbanIP(jail, ip) { + const confirmMsg = isLOTRModeActive + ? 'Restore ' + ip + ' to the realm from ' + jail + '?' + : 'Unban IP ' + ip + ' from jail ' + jail + '?'; + if (!confirm(confirmMsg)) { return; } - - var jail = jailSelect.value; - var ip = ipInput.value.trim(); - - if (!jail) { - showToast(t('dashboard.manual_block.jail_required', 'Please select a jail'), 'error'); - jailSelect.focus(); - return; - } - - if (!ip) { - showToast(t('dashboard.manual_block.ip_required', 'Please enter an IP address'), 'error'); - ipInput.focus(); - return; - } - - // Basic IP validation - var ipv4Pattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/; - var ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; - if (!ipv4Pattern.test(ip) && !ipv6Pattern.test(ip)) { - showToast(t('dashboard.manual_block.invalid_ip', 'Please enter a valid IP address'), 'error'); - ipInput.focus(); - return; - } - - banIP(jail, ip); - - // Clear form after submission - ipInput.value = ''; - jailSelect.value = ''; + 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'); + } + return refreshData({ silent: true }); + }) + .catch(function(err) { + showToast("Error: " + err, 'error'); + }) + .finally(function() { + showLoading(false); + }); } +// ========================================================================= +// Main Dashboard Rendering Function +// ========================================================================= + +// Rendering the upper part of the dashboard. 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 = '' @@ -596,10 +234,8 @@ function renderDashboard() { restoreFocusState(focusState); return; } - var summary = latestSummary; var html = ''; - // Persistent warning banner when jail.local is not managed by Fail2ban-UI if (jailLocalWarning) { html += '' @@ -613,24 +249,23 @@ function renderDashboard() { + ' ' + ''; } - if (latestSummaryError) { html += '' + '
' + escapeHtml(latestSummaryError) + '
'; } - + // If there is no summary data, we show a loading message if (!summary) { html += '' + '
' + '

Loading summary data…

' + '
'; } else { + // If there is "summary data", we render the complete upper part of the dashboard here. 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 += '' + '
' + '
' @@ -650,9 +285,7 @@ function renderDashboard() { + '

' + recurringWeekCount + '

' + '

Keep an eye on repeated offenders across all servers.

' + '
' - + '
'; - - html += '' + + '' + '
' + '
' + '
' @@ -665,7 +298,6 @@ function renderDashboard() { + ' ' + '
' + '
'; - if (!summary.jails || summary.jails.length === 0) { html += '

No jails found.

'; } else { @@ -681,7 +313,6 @@ function renderDashboard() { + ' ' + ' ' + ' '; - summary.jails.forEach(function(jail) { var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs || []); html += '' @@ -700,14 +331,12 @@ function renderDashboard() { html += ' '; html += '
'; } - - html += ''; // close overview card + html += ''; } - - // Manual Block IP Section if (summary && summary.jails && summary.jails.length > 0) { var enabledJails = summary.jails.filter(function(j) { return j.enabled !== false; }); if (enabledJails.length > 0) { + // Rendering the manual ban-block from the dashboard here html += '' + '
' + '
' @@ -733,7 +362,7 @@ function renderDashboard() { enabledJails.forEach(function(jail) { html += ' '; }); - + // Rendering the end of the manual ban-block form after fill in the enabled jails html += '' + ' ' + '
' @@ -753,12 +382,9 @@ function renderDashboard() { + '
'; } } - html += '
' + renderLogOverviewContent() + '
'; - container.innerHTML = html; restoreFocusState(focusState); - const extIpEl = document.getElementById('external-ip'); if (extIpEl) { extIpEl.addEventListener('click', function() { @@ -772,29 +398,73 @@ function renderDashboard() { } }); } - 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(); +// ========================================================================= +// Rendering the colapsable "Banned IPs per jail" section +// ========================================================================= + +function renderBannedIPs(jailName, ips) { + if (!ips || ips.length === 0) { + return 'No banned IPs'; } + 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 = '
'; + function bannedIpRow(ip) { + var safeIp = escapeHtml(ip); + var encodedIp = encodeURIComponent(ip); + return '' + + '
' + + ' ' + safeIp + '' + + ' ' + + '
'; + } + visible.forEach(function(ip) { + content += bannedIpRow(ip); + }); + if (hidden.length) { + content += ''; + var moreLabel = t('dashboard.banned.show_more', 'Show more') + ' +' + hidden.length; + var lessLabel = t('dashboard.banned.show_less', 'Hide extra'); + content += '' + + ''; + } + content += '
'; + return content; } +// ========================================================================= +// Internal Log Overview Section Functions +// ========================================================================= + function renderLogOverviewContent() { var html = '' + '
' @@ -805,7 +475,6 @@ function renderLogOverviewContent() { + '
' + ' ' + ' '; - var statsKeys = Object.keys(latestBanStats || {}); statsKeys.sort(function(a, b) { return (latestBanStats[b] || 0) - (latestBanStats[a] || 0); @@ -813,7 +482,6 @@ function renderLogOverviewContent() { var totalStored = totalStoredBans(); var todayCount = totalBansToday(); var weekCount = totalBansWeek(); - if (statsKeys.length === 0 && totalStored === 0) { html += '

No ban events recorded yet.

'; } else { @@ -863,15 +531,12 @@ function renderLogOverviewContent() { } html += ' '; } - html += '

Recent stored events

'; - - // Always show search bar and table (like Search Banned IPs) so user can clear search when no matches var countries = getBanEventCountries(); var recurringMap = getRecurringIPMap(); var searchQuery = (banEventsFilterText || '').trim(); var totalLabel = banEventsTotal != null ? banEventsTotal : '—'; - + // Rendering the search and filter options for the recent stored events html += '' + '
' + '
' @@ -882,22 +547,19 @@ function renderLogOverviewContent() { + ' ' + ' ' + '
' - + '
'; - - html += '

' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + latestBanEvents.length + ' / ' + totalLabel + '

'; - - html += '' + + '' + + '

' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + latestBanEvents.length + ' / ' + totalLabel + '

' + '
' + ' ' + ' ' @@ -911,7 +573,6 @@ function renderLogOverviewContent() { + ' ' + ' ' + ' '; - if (!latestBanEvents.length) { var hasFilter = (banEventsFilterText || '').trim().length > 0 || ((banEventsFilterCountry || 'all').trim() !== 'all'); var emptyMsgKey = hasFilter ? 'logs.overview.recent_filtered_empty' : 'logs.overview.recent_empty'; @@ -952,7 +613,6 @@ function renderLogOverviewContent() { + ' '; }); } - html += '
'; if (banEventsHasMore && latestBanEvents.length > 0 && latestBanEvents.length < BAN_EVENTS_MAX_LOADED) { var loadMoreLabel = typeof t === 'function' ? t('logs.overview.load_more', 'Load more') : 'Load more'; @@ -960,7 +620,329 @@ function renderLogOverviewContent() { + '' + ''; } - html += ''; return html; } + +// ========================================================================= +// Search and Filtering Functions +// ========================================================================= + +function updateBanEventsSearch(value) { + banEventsFilterText = value || ''; + scheduleBanEventsRefetch(); +} + +function updateBanEventsCountry(value) { + banEventsFilterCountry = value || 'all'; + fetchBanEventsData().then(function() { + renderLogOverviewSection(); + }); +} + +function loadMoreBanEvents() { + if (latestBanEvents.length >= BAN_EVENTS_MAX_LOADED || !banEventsHasMore) { + return; + } + fetchBanEventsData({ append: true }).then(function() { + renderLogOverviewSection(); + }); +} + +// Filtering function for the banned IPs for the dashboard. +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"; + }); +} + +// ========================================================================= +// Helper Functions +// ========================================================================= + +// Helper function to toggle the banned list section for the dashboard. +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"); + } +} + +// Helper function to toggle the manual block section for the dashboard. +function toggleManualBlockSection() { + var container = document.getElementById('manualBlockFormContainer'); + var icon = document.getElementById('manualBlockToggleIcon'); + if (!container || !icon) { + return; + } + var isHidden = container.classList.contains("hidden"); + if (isHidden) { + container.classList.remove("hidden"); + icon.classList.remove("fa-chevron-down"); + icon.classList.add("fa-chevron-up"); + } else { + container.classList.add("hidden"); + icon.classList.remove("fa-chevron-up"); + icon.classList.add("fa-chevron-down"); + } +} + +// This handles manual block actions and calls the banIP function. +function handleManualBlock() { + var jailSelect = document.getElementById('blockJailSelect'); + var ipInput = document.getElementById('blockIPInput'); + if (!jailSelect || !ipInput) { + return; + } + var jail = jailSelect.value; + var ip = ipInput.value.trim(); + if (!jail) { + showToast(t('dashboard.manual_block.jail_required', 'Please select a jail'), 'error'); + jailSelect.focus(); + return; + } + if (!ip) { + showToast(t('dashboard.manual_block.ip_required', 'Please enter an IP address'), 'error'); + ipInput.focus(); + return; + } + // IPv4 / IPv6 validation + var ipv4Pattern = /^([0-9]{1,3}\.){3}[0-9]{1,3}$/; + var ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/; + if (!ipv4Pattern.test(ip) && !ipv6Pattern.test(ip)) { + showToast(t('dashboard.manual_block.invalid_ip', 'Please enter a valid IP address'), 'error'); + ipInput.focus(); + return; + } + banIP(jail, ip); + ipInput.value = ''; + jailSelect.value = ''; +} + +// Helper function to add the "Internal Log Overview" content to the dashboard. +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 buildBanEventsQuery(offset, append) { + var params = [ + 'limit=' + BAN_EVENTS_PAGE_SIZE, + 'offset=' + (append ? Math.min(latestBanEvents.length, BAN_EVENTS_MAX_LOADED) : 0) + ]; + var search = (banEventsFilterText || '').trim(); + if (search) { + params.push('search=' + encodeURIComponent(search)); + } + var country = (banEventsFilterCountry || 'all').trim(); + if (country && country !== 'all') { + params.push('country=' + encodeURIComponent(country)); + } + if (currentServerId) { + params.push('serverId=' + encodeURIComponent(currentServerId)); + } + return '/api/events/bans?' + params.join('&'); +} + +// Helper function to add a new ban event from the WebSocket to the dashboard. +function addBanEventFromWebSocket(event) { + var hasSearch = (banEventsFilterText || '').trim().length > 0; + if (hasSearch) { + if (typeof showBanEventToast === 'function') { + showBanEventToast(event); + } + refreshDashboardData(); + return; + } + var exists = false; + if (event.id) { + exists = latestBanEvents.some(function(e) { return e.id === event.id; }); + } else { + exists = latestBanEvents.some(function(e) { + return e.ip === event.ip && e.jail === event.jail && e.eventType === event.eventType && e.occurredAt === event.occurredAt; + }); + } + if (!exists) { + if (!event.eventType) { + event.eventType = 'ban'; + } + console.log('Adding new event from WebSocket:', event); + latestBanEvents.unshift(event); + if (latestBanEvents.length > BAN_EVENTS_MAX_LOADED) { + latestBanEvents = latestBanEvents.slice(0, BAN_EVENTS_MAX_LOADED); + } + if (typeof showBanEventToast === 'function') { + showBanEventToast(event); + } + refreshDashboardData(); + } else { + console.log('Skipping duplicate event:', event); + } +} + +// Helper function to refresh the dashboard data by fetching the summary and ban insights. +function refreshDashboardData() { + var enabledServers = serversCache.filter(function(s) { return s.enabled; }); + var summaryPromise; + if (serversCache.length && enabledServers.length && currentServerId) { + summaryPromise = fetchSummaryData(); + } else { + summaryPromise = Promise.resolve(); + } + Promise.all([ + summaryPromise, + fetchBanStatisticsData(), + fetchBanInsightsData() + ]).then(function() { + renderDashboard(); + }).catch(function(err) { + console.error('Error refreshing dashboard data:', err); + renderDashboard(); + }); +} + +// Helper functions to query the total number of banned IPs +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); +} + +// Helper functions to query the total number of banned IPs of today. +function totalBansToday() { + if (latestBanInsights && latestBanInsights.totals && typeof latestBanInsights.totals.today === 'number') { + return latestBanInsights.totals.today; + } + return 0; +} + +// Helper functions to query the total number of banned IPs of last week. +function totalBansWeek() { + if (latestBanInsights && latestBanInsights.totals && typeof latestBanInsights.totals.week === 'number') { + return latestBanInsights.totals.week; + } + return 0; +} + +// Helper functions to query the total number of recurring IPs of last week. +function recurringIPsLastWeekCount() { + var source = latestServerInsights || latestBanInsights; + if (!source || !Array.isArray(source.recurring)) { + return 0; + } + return source.recurring.length; +} + +// Helper functions to query the countries of the banned IPs. +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]; + }); +} + +// Helper functions to schedule the refetch of the banned events. +function scheduleBanEventsRefetch() { + if (banEventsFilterDebounce) { + clearTimeout(banEventsFilterDebounce); + } + banEventsFilterDebounce = setTimeout(function() { + banEventsFilterDebounce = null; + fetchBanEventsData().then(function() { + renderLogOverviewSection(); + }); + }, 300); +} + +// Helper functions to query the recurring IPs of the banned IPs. +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; +} diff --git a/pkg/web/static/js/filters.js b/pkg/web/static/js/filters.js index e98e130..f854be0 100644 --- a/pkg/web/static/js/filters.js +++ b/pkg/web/static/js/filters.js @@ -1,6 +1,58 @@ // Filter debug functions for Fail2ban UI "use strict"; +// ========================================================================= +// Filter creation +// ========================================================================= + +function createFilter() { + const filterName = document.getElementById('newFilterName').value.trim(); + const content = document.getElementById('newFilterContent').value.trim(); + + if (!filterName) { + showToast('Filter name is required', 'error'); + return; + } + + showLoading(true); + fetch(withServerParam('/api/filters'), { + method: 'POST', + headers: serverHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + filterName: filterName, + content: content + }) + }) + .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 creating filter: ' + data.error, 'error'); + return; + } + closeModal('createFilterModal'); + showToast(data.message || 'Filter created successfully', 'success'); + loadFilters(); + }) + .catch(function(err) { + console.error('Error creating filter:', err); + showToast('Error creating filter: ' + (err.message || err), 'error'); + }) + .finally(function() { + showLoading(false); + }); +} + +// ========================================================================= +// Filter Loading +// ========================================================================= + function loadFilters() { showLoading(true); fetch(withServerParam('/api/filters'), { @@ -38,12 +90,10 @@ function loadFilters() { opt.textContent = f; select.appendChild(opt); }); - // Add change listener if not already added if (!select.hasAttribute('data-listener-added')) { select.setAttribute('data-listener-added', 'true'); select.addEventListener('change', function() { if (deleteBtn) deleteBtn.disabled = !select.value; - // Load filter content when a filter is selected if (select.value) { loadFilterContent(select.value); } else { @@ -61,7 +111,6 @@ function loadFilters() { }); } if (deleteBtn) deleteBtn.disabled = !select.value; - // If a filter is already selected (e.g., first one by default), load its content if (select.value) { loadFilterContent(select.value); } @@ -93,7 +142,7 @@ function loadFilterContent(filterName) { return; } filterContentTextarea.value = data.content || ''; - filterContentTextarea.readOnly = true; // Keep it readonly by default + filterContentTextarea.readOnly = true; filterContentTextarea.classList.add('bg-gray-50'); filterContentTextarea.classList.remove('bg-white'); if (editBtn) editBtn.classList.remove('hidden'); @@ -109,13 +158,15 @@ function loadFilterContent(filterName) { .finally(() => showLoading(false)); } +// ========================================================================= +// Filter Editing (on the filter section) +// ========================================================================= + function toggleFilterContentEdit() { const filterContentTextarea = document.getElementById('filterContentTextarea'); const editBtn = document.getElementById('editFilterContentBtn'); if (!filterContentTextarea) return; - if (filterContentTextarea.readOnly) { - // Make editable filterContentTextarea.readOnly = false; filterContentTextarea.classList.remove('bg-gray-50'); filterContentTextarea.classList.add('bg-white'); @@ -126,7 +177,6 @@ function toggleFilterContentEdit() { } updateFilterContentHints(true); } else { - // Make readonly filterContentTextarea.readOnly = true; filterContentTextarea.classList.add('bg-gray-50'); filterContentTextarea.classList.remove('bg-white'); @@ -142,7 +192,7 @@ function toggleFilterContentEdit() { function updateFilterContentHints(isEditable) { const readonlyHint = document.querySelector('p[data-i18n="filter_debug.filter_content_hint_readonly"]'); const editableHint = document.getElementById('filterContentHintEditable'); - + if (isEditable) { if (readonlyHint) readonlyHint.classList.add('hidden'); if (editableHint) editableHint.classList.remove('hidden'); @@ -150,12 +200,72 @@ function updateFilterContentHints(isEditable) { if (readonlyHint) readonlyHint.classList.remove('hidden'); if (editableHint) editableHint.classList.add('hidden'); } - if (typeof updateTranslations === 'function') { updateTranslations(); } } +// ========================================================================= +// Filter deletion +// ========================================================================= + +function deleteFilter() { + const filterName = document.getElementById('filterSelect').value; + if (!filterName) { + showToast('Please select a filter to delete', 'info'); + return; + } + + if (!confirm('Are you sure you want to delete the filter "' + escapeHtml(filterName) + '"? This action cannot be undone.')) { + return; + } + showLoading(true); + fetch(withServerParam('/api/filters/' + encodeURIComponent(filterName)), { + method: 'DELETE', + headers: serverHeaders() + }) + .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 deleting filter: ' + data.error, 'error'); + return; + } + showToast(data.message || 'Filter deleted successfully', 'success'); + loadFilters(); + document.getElementById('testResults').innerHTML = ''; + document.getElementById('testResults').classList.add('hidden'); + document.getElementById('logLinesTextarea').value = ''; + const filterContentTextarea = document.getElementById('filterContentTextarea'); + const editBtn = document.getElementById('editFilterContentBtn'); + if (filterContentTextarea) { + filterContentTextarea.value = ''; + filterContentTextarea.readOnly = true; + filterContentTextarea.classList.add('bg-gray-50'); + filterContentTextarea.classList.remove('bg-white'); + } + if (editBtn) editBtn.classList.add('hidden'); + updateFilterContentHints(false); + }) + .catch(function(err) { + console.error('Error deleting filter:', err); + showToast('Error deleting filter: ' + (err.message || err), 'error'); + }) + .finally(function() { + showLoading(false); + }); +} + +// ========================================================================= +// Filter Testing +// ========================================================================= + function testSelectedFilter() { const filterName = document.getElementById('filterSelect').value; const lines = document.getElementById('logLinesTextarea').value.split('\n').filter(line => line.trim() !== ''); @@ -165,32 +275,24 @@ function testSelectedFilter() { 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); const requestBody = { filterName: filterName, logLines: lines }; - - // Only include filter content if textarea is editable (not readonly) - // If readonly, test the original filter from server if (filterContentTextarea && !filterContentTextarea.readOnly) { const filterContent = filterContentTextarea.value.trim(); if (filterContent) { requestBody.filterContent = filterContent; } } - fetch(withServerParam('/api/filters/test'), { method: 'POST', headers: serverHeaders({ 'Content-Type': 'application/json' }), @@ -213,15 +315,13 @@ function testSelectedFilter() { function renderTestResults(output, filterPath) { const testResultsEl = document.getElementById('testResults'); let html = '
Test Results
'; - - // Show which filter file was used + if (filterPath) { html += '
'; html += 'Used Filter (exact file): '; html += '' + escapeHtml(filterPath) + ''; html += '
'; } - if (!output || output.trim() === '') { html += '

No output received.

'; } else { @@ -234,6 +334,10 @@ function renderTestResults(output, filterPath) { } } +// ========================================================================= +// Filter Section Init +// ========================================================================= + function showFilterSection() { const testResultsEl = document.getElementById('testResults'); const filterContentTextarea = document.getElementById('filterContentTextarea'); @@ -267,7 +371,6 @@ function showFilterSection() { } if (editBtn) editBtn.classList.add('hidden'); updateFilterContentHints(false); - // Add change listener to enable/disable delete button and load filter content const filterSelect = document.getElementById('filterSelect'); const deleteBtn = document.getElementById('deleteFilterBtn'); if (!filterSelect.hasAttribute('data-listener-added')) { @@ -290,111 +393,3 @@ function showFilterSection() { }); } } - -function openCreateFilterModal() { - document.getElementById('newFilterName').value = ''; - document.getElementById('newFilterContent').value = ''; - openModal('createFilterModal'); -} - -function createFilter() { - const filterName = document.getElementById('newFilterName').value.trim(); - const content = document.getElementById('newFilterContent').value.trim(); - - if (!filterName) { - showToast('Filter name is required', 'error'); - return; - } - - showLoading(true); - fetch(withServerParam('/api/filters'), { - method: 'POST', - headers: serverHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ - filterName: filterName, - content: content - }) - }) - .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 creating filter: ' + data.error, 'error'); - return; - } - closeModal('createFilterModal'); - showToast(data.message || 'Filter created successfully', 'success'); - // Reload filters - loadFilters(); - }) - .catch(function(err) { - console.error('Error creating filter:', err); - showToast('Error creating filter: ' + (err.message || err), 'error'); - }) - .finally(function() { - showLoading(false); - }); -} - -function deleteFilter() { - const filterName = document.getElementById('filterSelect').value; - if (!filterName) { - showToast('Please select a filter to delete', 'info'); - return; - } - - if (!confirm('Are you sure you want to delete the filter "' + escapeHtml(filterName) + '"? This action cannot be undone.')) { - return; - } - - showLoading(true); - fetch(withServerParam('/api/filters/' + encodeURIComponent(filterName)), { - method: 'DELETE', - headers: serverHeaders() - }) - .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 deleting filter: ' + data.error, 'error'); - return; - } - showToast(data.message || 'Filter deleted successfully', 'success'); - // Reload filters - loadFilters(); - // Clear test results - document.getElementById('testResults').innerHTML = ''; - document.getElementById('testResults').classList.add('hidden'); - document.getElementById('logLinesTextarea').value = ''; - const filterContentTextarea = document.getElementById('filterContentTextarea'); - const editBtn = document.getElementById('editFilterContentBtn'); - if (filterContentTextarea) { - filterContentTextarea.value = ''; - filterContentTextarea.readOnly = true; - filterContentTextarea.classList.add('bg-gray-50'); - filterContentTextarea.classList.remove('bg-white'); - } - if (editBtn) editBtn.classList.add('hidden'); - updateFilterContentHints(false); - }) - .catch(function(err) { - console.error('Error deleting filter:', err); - showToast('Error deleting filter: ' + (err.message || err), 'error'); - }) - .finally(function() { - showLoading(false); - }); -} - diff --git a/pkg/web/static/js/jails.js b/pkg/web/static/js/jails.js index 1c3ce70..dcf221d 100644 --- a/pkg/web/static/js/jails.js +++ b/pkg/web/static/js/jails.js @@ -1,133 +1,56 @@ // 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 +// ========================================================================= +// Jail creation +// ========================================================================= + +function createJail() { + const jailName = document.getElementById('newJailName').value.trim(); + const content = document.getElementById('newJailContent').value.trim(); + + if (!jailName) { + showToast('Jail name is required', 'error'); + return; } -} - -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() + fetch(withServerParam('/api/jails'), { + method: 'POST', + headers: serverHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + jailName: jailName, + content: content + }) }) - .then(function(res) { return res.json(); }) + .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 loading config: " + data.error, 'error'); + showToast('Error creating jail: ' + data.error, 'error'); return; } - filterTextArea.value = data.filter || ''; - jailTextArea.value = data.jailConfig || ''; - - // Display file paths if available - var filterFilePathEl = document.getElementById('filterFilePath'); - var jailFilePathEl = document.getElementById('jailFilePath'); - if (filterFilePathEl && data.filterFilePath) { - filterFilePathEl.textContent = data.filterFilePath; - filterFilePathEl.style.display = 'block'; - } else if (filterFilePathEl) { - filterFilePathEl.style.display = 'none'; - } - if (jailFilePathEl && data.jailFilePath) { - jailFilePathEl.textContent = data.jailFilePath; - jailFilePathEl.style.display = 'block'; - } else if (jailFilePathEl) { - jailFilePathEl.style.display = 'none'; - } - - // Check if logpath is set in jail config and show test button - updateLogpathButtonVisibility(); - - // Show hint for local servers - var localServerHint = document.getElementById('localServerLogpathHint'); - if (localServerHint && currentServer && currentServer.type === 'local') { - localServerHint.classList.remove('hidden'); - } else if (localServerHint) { - localServerHint.classList.add('hidden'); - } - - // 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); + closeModal('createJailModal'); + showToast(data.message || 'Jail created successfully', 'success'); + openManageJailsModal(); }) .catch(function(err) { - showToast("Error: " + err, 'error'); + console.error('Error creating jail:', err); + showToast('Error creating jail: ' + (err.message || 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'); - var localServerHint = document.getElementById('localServerLogpathHint'); - - if (hasLogpath && testSection) { - testSection.classList.remove('hidden'); - // Show hint for local servers - if (localServerHint && currentServer && currentServer.type === 'local') { - localServerHint.classList.remove('hidden'); - } else if (localServerHint) { - localServerHint.classList.add('hidden'); - } - } else if (testSection) { - testSection.classList.add('hidden'); - document.getElementById('logpathResults').classList.add('hidden'); - if (localServerHint) { - localServerHint.classList.add('hidden'); - } - } -} +// ========================================================================= +// Jail configuration saving +// ========================================================================= function saveJailConfig() { if (!currentJailForConfig) return; @@ -178,28 +101,195 @@ function saveJailConfig() { }); } -// Extract logpath from jail config text -// Supports multiple logpaths in a single line (space-separated) or multiple lines -// Fail2ban supports both formats: -// logpath = /var/log/file1.log /var/log/file2.log -// logpath = /var/log/file1.log -// /var/log/file2.log +function updateJailConfigFromFilter() { + const filterSelect = document.getElementById('newJailFilter'); + const jailNameInput = document.getElementById('newJailName'); + const contentTextarea = document.getElementById('newJailContent'); + + if (!filterSelect || !contentTextarea) return; + const selectedFilter = filterSelect.value; + + if (!selectedFilter) { + return; + } + if (jailNameInput && !jailNameInput.value.trim()) { + jailNameInput.value = selectedFilter; + } + + const jailName = (jailNameInput && jailNameInput.value.trim()) || selectedFilter; + const config = `[${jailName}] +enabled = false +filter = ${selectedFilter} +logpath = /var/log/auth.log +maxretry = 5 +bantime = 3600 +findtime = 600`; + + contentTextarea.value = config; +} + +// ========================================================================= +// Jail toggle enable/disable state of single jails +// ========================================================================= + +function saveManageJailsSingle(checkbox) { + const item = checkbox.closest('div.flex.items-center.justify-between'); + if (!item) { + console.error('Could not find parent container for checkbox'); + return; + } + + 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); + + 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) { + var errorMsg = data.error; + var toastType = 'error'; + // If jails were auto-disabled, check if this jail was one of them + var wasAutoDisabled = data.autoDisabled && data.enabledJails && Array.isArray(data.enabledJails) && data.enabledJails.indexOf(jailName) !== -1; + + if (wasAutoDisabled) { + checkbox.checked = false; + toastType = 'warning'; + } else { + // Revert checkbox state if error occurs + checkbox.checked = !isEnabled; + } + showToast(errorMsg, toastType, wasAutoDisabled ? 15000 : undefined); + + // 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) { + 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 }); + }); + }); + } + + if (data.warning) { + showToast(data.warning, 'warning'); + } + + console.log('Jail state saved successfully:', data); + showToast(data.message || ('Jail ' + jailName + ' ' + (isEnabled ? 'enabled' : 'disabled') + ' successfully'), 'success'); + return fetch(withServerParam('/api/jails/manage'), { + headers: serverHeaders() + }).then(function(res) { return res.json(); }) + .then(function(data) { + if (data.jails && data.jails.length) { + 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'); + checkbox.checked = !isEnabled; + }); +} + +// ========================================================================= +// Jail deletion +// ========================================================================= + +function deleteJail(jailName) { + if (!confirm('Are you sure you want to delete the jail "' + escapeHtml(jailName) + '"? This action cannot be undone.')) { + return; + } + showLoading(true); + fetch(withServerParam('/api/jails/' + encodeURIComponent(jailName)), { + method: 'DELETE', + headers: serverHeaders() + }) + .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 deleting jail: ' + data.error, 'error'); + return; + } + showToast(data.message || 'Jail deleted successfully', 'success'); + openManageJailsModal(); + refreshData({ silent: true }); + }) + .catch(function(err) { + console.error('Error deleting jail:', err); + showToast('Error deleting jail: ' + (err.message || err), 'error'); + }) + .finally(function() { + showLoading(false); + }); +} + +// ========================================================================= +// Logpath Helpers +// ========================================================================= + +// Supported fail2ban logpath formats: space-separated / multi-line function extractLogpathFromConfig(configText) { if (!configText) return ''; - var logpaths = []; var lines = configText.split('\n'); var inLogpathLine = false; var currentLogpath = ''; - + for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); - // Skip comments if (line.startsWith('#')) { continue; } - - // Check if this line starts with logpath = + // Check if the line starts with logpath = var logpathMatch = line.match(/^logpath\s*=\s*(.+)$/i); if (logpathMatch && logpathMatch[1]) { // Trim whitespace and remove quotes if present @@ -207,15 +297,11 @@ function extractLogpathFromConfig(configText) { currentLogpath = currentLogpath.replace(/^["']|["']$/g, ''); inLogpathLine = true; } else if (inLogpathLine) { - // Continuation line (indented or starting with space) - // Fail2ban allows continuation lines for logpath + if (line !== '' && !line.includes('=')) { - // This is a continuation line, append to current logpath currentLogpath += ' ' + line.trim(); } else { - // End of logpath block, process current logpath if (currentLogpath) { - // Split by spaces to handle multiple logpaths in one line var paths = currentLogpath.split(/\s+/).filter(function(p) { return p.length > 0; }); logpaths = logpaths.concat(paths); currentLogpath = ''; @@ -223,7 +309,6 @@ function extractLogpathFromConfig(configText) { inLogpathLine = false; } } else if (inLogpathLine && line === '') { - // Empty line might end the logpath block if (currentLogpath) { var paths = currentLogpath.split(/\s+/).filter(function(p) { return p.length > 0; }); logpaths = logpaths.concat(paths); @@ -232,35 +317,52 @@ function extractLogpathFromConfig(configText) { inLogpathLine = false; } } - - // Process any remaining logpath + if (currentLogpath) { var paths = currentLogpath.split(/\s+/).filter(function(p) { return p.length > 0; }); logpaths = logpaths.concat(paths); } - - // Join multiple logpaths with newlines return logpaths.join('\n'); } +function updateLogpathButtonVisibility() { + var jailTextArea = document.getElementById('jailConfigTextarea'); + var jailConfig = jailTextArea ? jailTextArea.value : ''; + var hasLogpath = /logpath\s*=/i.test(jailConfig); + var testSection = document.getElementById('testLogpathSection'); + var localServerHint = document.getElementById('localServerLogpathHint'); + + if (hasLogpath && testSection) { + testSection.classList.remove('hidden'); + if (localServerHint && currentServer && currentServer.type === 'local') { + localServerHint.classList.remove('hidden'); + } else if (localServerHint) { + localServerHint.classList.add('hidden'); + } + } else if (testSection) { + testSection.classList.add('hidden'); + document.getElementById('logpathResults').classList.add('hidden'); + if (localServerHint) { + localServerHint.classList.add('hidden'); + } + } +} + function testLogpath() { if (!currentJailForConfig) return; - - // Extract logpath from the textarea + var jailTextArea = document.getElementById('jailConfigTextarea'); var jailConfig = jailTextArea ? jailTextArea.value : ''; var logpath = extractLogpathFromConfig(jailConfig); - + if (!logpath) { showToast('No logpath found in jail configuration. Please add a logpath line (e.g., logpath = /var/log/example.log)', 'warning'); 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), { @@ -274,49 +376,42 @@ function testLogpath() { if (data.error) { resultsDiv.textContent = 'Error: ' + data.error; resultsDiv.classList.add('text-red-600'); - // Auto-scroll to results setTimeout(function() { resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 100); return; } - var originalLogpath = data.original_logpath || ''; var results = data.results || []; var isLocalServer = data.is_local_server || false; - - // Build HTML output with visual indicators var output = ''; - + if (results.length === 0) { output = '
No logpath entries found.
'; resultsDiv.innerHTML = output; resultsDiv.classList.add('text-yellow-600'); return; } - - // Process each logpath result + results.forEach(function(result, idx) { var logpath = result.logpath || ''; var resolvedPath = result.resolved_path || ''; var found = result.found || false; var files = result.files || []; var error = result.error || ''; - + if (idx > 0) { output += '
'; } - + output += '
'; output += '
Logpath ' + (idx + 1) + ':
'; output += '
' + escapeHtml(logpath) + '
'; - + if (resolvedPath && resolvedPath !== logpath) { output += '
Resolved: ' + escapeHtml(resolvedPath) + '
'; } output += '
'; - - // Test results output += '
'; output += '
'; if (isLocalServer) { @@ -348,11 +443,10 @@ function testLogpath() { } output += '
'; }); - - // Set overall status color + var allFound = results.every(function(r) { return r.found; }); var anyFound = results.some(function(r) { return r.found; }); - + if (allFound) { resultsDiv.classList.remove('text-red-600', 'text-yellow-600'); } else if (anyFound) { @@ -362,10 +456,9 @@ function testLogpath() { resultsDiv.classList.remove('text-yellow-600'); resultsDiv.classList.add('text-red-600'); } - + resultsDiv.innerHTML = output; - - // Auto-scroll to results + setTimeout(function() { resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 100); @@ -374,356 +467,33 @@ function testLogpath() { showLoading(false); resultsDiv.textContent = 'Error: ' + err; resultsDiv.classList.add('text-red-600'); - // Auto-scroll to results setTimeout(function() { resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, 100); }); } -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; - } +// ========================================================================= +// Extension interference workaround +// ========================================================================= - 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 '' - + '
' - + ' ' + escapedJailName + '' - + '
' - + ' ' - + escapeHtml(t('modal.filter_config_edit', 'Edit Filter / Jail')) - + ' ' - + ' ' - + ' ' - + ' ' - + '
' - + ' ' - + ' ' - + '
' - + '
'; - }).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); - }); +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 }); - - openModal('manageJailsModal'); - }) - .catch(err => showToast("Error fetching jails: " + err, 'error')) - .finally(() => showLoading(false)); + } + Object.seal(element.control); + } catch (e) { } } - -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) { - // Check if there was an error (including auto-disabled jails) - if (data.error) { - var errorMsg = data.error; - var toastType = 'error'; - - // If jails were auto-disabled, check if this jail was one of them - var wasAutoDisabled = data.autoDisabled && data.enabledJails && Array.isArray(data.enabledJails) && data.enabledJails.indexOf(jailName) !== -1; - - if (wasAutoDisabled) { - checkbox.checked = false; - toastType = 'warning'; - } else { - // Revert checkbox state on error - checkbox.checked = !isEnabled; - } - - showToast(errorMsg, toastType, wasAutoDisabled ? 15000 : undefined); - - // Still 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 }); - }); - }); - } - - // Check for warning (legacy support) - if (data.warning) { - showToast(data.warning, 'warning'); - } - - console.log('Jail state saved successfully:', data); - // Show success toast - showToast(data.message || ('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; - }); -} - -function openCreateJailModal() { - document.getElementById('newJailName').value = ''; - document.getElementById('newJailContent').value = ''; - const filterSelect = document.getElementById('newJailFilter'); - if (filterSelect) { - filterSelect.value = ''; - } - - // Load filters into dropdown - showLoading(true); - fetch(withServerParam('/api/filters'), { - headers: serverHeaders() - }) - .then(res => res.json()) - .then(data => { - if (filterSelect) { - filterSelect.innerHTML = ''; - if (data.filters && data.filters.length > 0) { - data.filters.forEach(filter => { - const opt = document.createElement('option'); - opt.value = filter; - opt.textContent = filter; - filterSelect.appendChild(opt); - }); - } - } - openModal('createJailModal'); - }) - .catch(err => { - console.error('Error loading filters:', err); - openModal('createJailModal'); - }) - .finally(() => showLoading(false)); -} - -function updateJailConfigFromFilter() { - const filterSelect = document.getElementById('newJailFilter'); - const jailNameInput = document.getElementById('newJailName'); - const contentTextarea = document.getElementById('newJailContent'); - - if (!filterSelect || !contentTextarea) return; - - const selectedFilter = filterSelect.value; - - if (!selectedFilter) { - return; - } - - // Auto-fill jail name if empty - if (jailNameInput && !jailNameInput.value.trim()) { - jailNameInput.value = selectedFilter; - } - - // Auto-populate jail config - const jailName = (jailNameInput && jailNameInput.value.trim()) || selectedFilter; - const config = `[${jailName}] -enabled = false -filter = ${selectedFilter} -logpath = /var/log/auth.log -maxretry = 5 -bantime = 3600 -findtime = 600`; - - contentTextarea.value = config; -} - -function createJail() { - const jailName = document.getElementById('newJailName').value.trim(); - const content = document.getElementById('newJailContent').value.trim(); - - if (!jailName) { - showToast('Jail name is required', 'error'); - return; - } - - showLoading(true); - fetch(withServerParam('/api/jails'), { - method: 'POST', - headers: serverHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ - jailName: jailName, - content: content - }) - }) - .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 creating jail: ' + data.error, 'error'); - return; - } - closeModal('createJailModal'); - showToast(data.message || 'Jail created successfully', 'success'); - // Reload the manage jails modal - openManageJailsModal(); - }) - .catch(function(err) { - console.error('Error creating jail:', err); - showToast('Error creating jail: ' + (err.message || err), 'error'); - }) - .finally(function() { - showLoading(false); - }); -} - -function deleteJail(jailName) { - if (!confirm('Are you sure you want to delete the jail "' + escapeHtml(jailName) + '"? This action cannot be undone.')) { - return; - } - - showLoading(true); - fetch(withServerParam('/api/jails/' + encodeURIComponent(jailName)), { - method: 'DELETE', - headers: serverHeaders() - }) - .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 deleting jail: ' + data.error, 'error'); - return; - } - showToast(data.message || 'Jail deleted successfully', 'success'); - // Reload the manage jails modal - openManageJailsModal(); - // Refresh dashboard - refreshData({ silent: true }); - }) - .catch(function(err) { - console.error('Error deleting jail:', err); - showToast('Error deleting jail: ' + (err.message || err), 'error'); - }) - .finally(function() { - showLoading(false); - }); -} - diff --git a/pkg/web/static/js/modals.js b/pkg/web/static/js/modals.js index 0706681..52bf1e9 100644 --- a/pkg/web/static/js/modals.js +++ b/pkg/web/static/js/modals.js @@ -187,3 +187,232 @@ function openBanInsightsModal() { } openModal('banInsightsModal'); } + +// ========================================================================= +// Server Manager Modal +// ========================================================================= + +function openServerManager(serverId) { + showLoading(true); + loadServers() + .then(function() { + if (serverId) { + editServer(serverId); + } else { + resetServerForm(); + } + renderServerManagerList(); + openModal('serverManagerModal'); + }) + .finally(function() { + showLoading(false); + }); +} + +// ========================================================================= +// Manage Jails Modal +// ========================================================================= + +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); + const jsEscapedJailName = jail.jailName.replace(/'/g, "\\'"); + return '' + + '
' + + ' ' + escapedJailName + '' + + '
' + + ' ' + + escapeHtml(t('modal.filter_config_edit', 'Edit Filter / Jail')) + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + ' ' + + ' ' + + '
' + + ''; + }).join(''); + + document.getElementById('jailsList').innerHTML = html; + + let saveTimeout; + document.querySelectorAll('#jailsList input[type="checkbox"]').forEach(function(checkbox) { + checkbox.addEventListener('change', function() { + if (saveTimeout) { + clearTimeout(saveTimeout); + } + saveTimeout = setTimeout(function() { + saveManageJailsSingle(checkbox); + }, 300); + }); + }); + + openModal('manageJailsModal'); + }) + .catch(err => showToast("Error fetching jails: " + err, 'error')) + .finally(() => showLoading(false)); +} + +// ========================================================================= +// Create Jail Modal +// ========================================================================= + +function openCreateJailModal() { + document.getElementById('newJailName').value = ''; + document.getElementById('newJailContent').value = ''; + const filterSelect = document.getElementById('newJailFilter'); + if (filterSelect) { + filterSelect.value = ''; + } + showLoading(true); + fetch(withServerParam('/api/filters'), { + headers: serverHeaders() + }) + .then(res => res.json()) + .then(data => { + if (filterSelect) { + filterSelect.innerHTML = ''; + if (data.filters && data.filters.length > 0) { + data.filters.forEach(filter => { + const opt = document.createElement('option'); + opt.value = filter; + opt.textContent = filter; + filterSelect.appendChild(opt); + }); + } + } + openModal('createJailModal'); + }) + .catch(err => { + console.error('Error loading filters:', err); + openModal('createJailModal'); + }) + .finally(() => showLoading(false)); +} + +// ========================================================================= +// Create Filter Modal +// ========================================================================= + +function openCreateFilterModal() { + document.getElementById('newFilterName').value = ''; + document.getElementById('newFilterContent').value = ''; + openModal('createFilterModal'); +} + +// ========================================================================= +// Jail / Filter Config Editor Modal +// ========================================================================= + +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; + 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 || ''; + + var filterFilePathEl = document.getElementById('filterFilePath'); + var jailFilePathEl = document.getElementById('jailFilePath'); + if (filterFilePathEl && data.filterFilePath) { + filterFilePathEl.textContent = data.filterFilePath; + filterFilePathEl.style.display = 'block'; + } else if (filterFilePathEl) { + filterFilePathEl.style.display = 'none'; + } + if (jailFilePathEl && data.jailFilePath) { + jailFilePathEl.textContent = data.jailFilePath; + jailFilePathEl.style.display = 'block'; + } else if (jailFilePathEl) { + jailFilePathEl.style.display = 'none'; + } + + // Update logpath button visibility + updateLogpathButtonVisibility(); + + // Show hint for local servers + var localServerHint = document.getElementById('localServerLogpathHint'); + if (localServerHint && currentServer && currentServer.type === 'local') { + localServerHint.classList.remove('hidden'); + } else if (localServerHint) { + localServerHint.classList.add('hidden'); + } + + jailTextArea.addEventListener('input', updateLogpathButtonVisibility); + + preventExtensionInterference(filterTextArea); + preventExtensionInterference(jailTextArea); + openModal('jailConfigModal'); + + setTimeout(function() { + preventExtensionInterference(filterTextArea); + preventExtensionInterference(jailTextArea); + }, 200); + }) + .catch(function(err) { + showToast("Error: " + err, 'error'); + }) + .finally(function() { + showLoading(false); + }); +} diff --git a/pkg/web/static/js/servers.js b/pkg/web/static/js/servers.js index 4f755e2..528ae22 100644 --- a/pkg/web/static/js/servers.js +++ b/pkg/web/static/js/servers.js @@ -1,6 +1,10 @@ -// Server management functions for Fail2ban UI +// Server management javascript functions for Fail2ban UI "use strict"; +// ========================================================================= +// Server data loading +// ========================================================================= + function loadServers() { return fetch('/api/servers') .then(function(res) { return res.json(); }) @@ -35,6 +39,10 @@ function loadServers() { }); } +// ========================================================================= +// Views rendering +// ========================================================================= + function renderServerSelector() { var container = document.getElementById('serverSelectorContainer'); if (!container) return; @@ -104,38 +112,6 @@ function renderServerSubtitle() { 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'); @@ -189,7 +165,7 @@ function renderServerManagerList() { + ' ' + (server.isDefault ? '' : '') + ' ' - + (server.enabled ? (server.type === 'local' + + (server.enabled ? (server.type === 'local' ? '' : '') : '') + ' ' @@ -202,7 +178,6 @@ function renderServerManagerList() { list.innerHTML = html; if (typeof updateTranslations === 'function') { updateTranslations(); - // Set tooltip text for reload buttons after translations are updated setTimeout(function() { serversCache.forEach(function(server) { if (server.enabled && server.type === 'local') { @@ -217,6 +192,25 @@ function renderServerManagerList() { } } +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(); +} + +// ========================================================================= +// Server manager form actions +// ========================================================================= + function resetServerForm() { document.getElementById('serverId').value = ''; document.getElementById('serverName').value = ''; @@ -406,13 +400,11 @@ function populateSSHKeySelect(keys, selected) { if (typeof updateTranslations === 'function') { updateTranslations(); } - // Sync readonly state of the path input syncSSHKeyPathReadonly(); - // Attach change handler once initSSHKeySelectHandler(); } -// Toggle the SSH key path input between readonly (key selected) and editable (manual entry). +// SSH key path input is readonly when a key is selected, editable for manual entry. function syncSSHKeyPathReadonly() { var select = document.getElementById('serverSSHKeySelect'); var input = document.getElementById('serverSSHKey'); @@ -426,7 +418,6 @@ function syncSSHKeyPathReadonly() { } } -// Attach a change listener on the select dropdown (once). var _sshKeySelectHandlerBound = false; function initSSHKeySelectHandler() { if (_sshKeySelectHandlerBound) return; @@ -463,6 +454,10 @@ function loadSSHKeys() { }); } +// ========================================================================= +// Server Actions +// ========================================================================= + function setServerEnabled(serverId, enabled) { var server = serversCache.find(function(s) { return s.id === serverId; }); if (!server) { @@ -632,4 +627,3 @@ 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); } - diff --git a/pkg/web/static/js/settings.js b/pkg/web/static/js/settings.js index 4ef3bad..e17a7f9 100644 --- a/pkg/web/static/js/settings.js +++ b/pkg/web/static/js/settings.js @@ -1,45 +1,9 @@ -// Settings page functions for Fail2ban UI +// Settings page javascript logics for Fail2ban UI. "use strict"; -// Handle GeoIP provider change -function onGeoIPProviderChange(provider) { - const dbPathContainer = document.getElementById('geoipDatabasePathContainer'); - if (dbPathContainer) { - if (provider === 'maxmind') { - dbPathContainer.style.display = 'block'; - } else { - dbPathContainer.style.display = 'none'; - } - } -} - -// Update email fields state based on checkbox preferences -function updateEmailFieldsState() { - const emailAlertsForBans = document.getElementById('emailAlertsForBans').checked; - const emailAlertsForUnbans = document.getElementById('emailAlertsForUnbans').checked; - const emailEnabled = emailAlertsForBans || emailAlertsForUnbans; - - // Get all email-related fields - const emailFields = [ - document.getElementById('destEmail'), - document.getElementById('smtpHost'), - document.getElementById('smtpPort'), - document.getElementById('smtpUsername'), - document.getElementById('smtpPassword'), - document.getElementById('smtpFrom'), - document.getElementById('smtpAuthMethod'), - document.getElementById('smtpUseTLS'), - document.getElementById('smtpInsecureSkipVerify'), - document.getElementById('sendTestEmailBtn') - ]; - - // Enable/disable all email fields - emailFields.forEach(field => { - if (field) { - field.disabled = !emailEnabled; - } - }); -} +// ========================================================================= +// Load Settings +// ========================================================================= function loadSettings() { showLoading(true); @@ -48,14 +12,12 @@ function loadSettings() { .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'); @@ -63,7 +25,6 @@ function loadSettings() { 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'); @@ -75,14 +36,12 @@ function loadSettings() { const consoleOutputEl = document.getElementById('consoleOutput'); if (consoleOutputEl) { consoleOutputEl.checked = data.consoleOutput || false; - // Mark that console was enabled on load (settings were already saved) if (typeof wasConsoleEnabledOnLoad !== 'undefined') { wasConsoleEnabledOnLoad = consoleOutputEl.checked; } - toggleConsoleOutput(false); // false = not a user click, loading from saved settings + toggleConsoleOutput(false); } - // Set callback URL and add auto-update listener for port changes const callbackURLInput = document.getElementById('callbackURL'); callbackURLInput.value = data.callbackUrl || ''; const callbackUrlEnvHint = document.getElementById('callbackUrlEnvHint'); @@ -90,7 +49,6 @@ function loadSettings() { const callbackUrlDefaultHint = document.getElementById('callbackUrlDefaultHint'); if (data.callbackUrlEnvSet) { - // CALLBACK_URL env is set - make field readonly and show hint callbackURLInput.value = data.callbackUrlFromEnv || data.callbackUrl || ''; callbackURLInput.readOnly = true; callbackURLInput.classList.add('bg-gray-100', 'cursor-not-allowed'); @@ -98,7 +56,6 @@ function loadSettings() { callbackUrlEnvHint.style.display = 'block'; callbackUrlDefaultHint.style.display = 'none'; } else { - // CALLBACK_URL env not set - allow editing callbackURLInput.readOnly = false; callbackURLInput.classList.remove('bg-gray-100', 'cursor-not-allowed'); callbackUrlEnvHint.style.display = 'none'; @@ -109,34 +66,27 @@ function loadSettings() { const toggleLink = document.getElementById('toggleCallbackSecretLink'); if (callbackSecretInput) { callbackSecretInput.value = data.callbackSecret || ''; - // Reset to password type when loading if (callbackSecretInput.type === 'text') { callbackSecretInput.type = 'password'; } - // Update link text if (toggleLink) { toggleLink.textContent = 'show secret'; } } - // Auto-update callback URL when port changes (if using default localhost pattern) + // Syncs callback URL when port changes (only when using localhost) function updateCallbackURLIfDefault() { - if (data.callbackUrlEnvSet) return; // Skip auto-update when env is set + if (data.callbackUrlEnvSet) return; 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 || ''; - - // Load email alert preferences document.getElementById('emailAlertsForBans').checked = data.emailAlertsForBans !== undefined ? data.emailAlertsForBans : true; document.getElementById('emailAlertsForUnbans').checked = data.emailAlertsForUnbans !== undefined ? data.emailAlertsForUnbans : false; updateEmailFieldsState(); @@ -156,10 +106,7 @@ function loadSettings() { } } $('#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; @@ -174,7 +121,6 @@ function loadSettings() { document.getElementById('bantimeIncrement').checked = data.bantimeIncrement || false; document.getElementById('defaultJailEnable').checked = data.defaultJailEnable || false; - // GeoIP settings const geoipProvider = data.geoipProvider || 'builtin'; document.getElementById('geoipProvider').value = geoipProvider; onGeoIPProviderChange(geoipProvider); @@ -185,11 +131,9 @@ function loadSettings() { document.getElementById('findTime').value = data.findtime || ''; document.getElementById('maxRetry').value = data.maxretry || ''; document.getElementById('defaultChain').value = data.chain || 'INPUT'; - // Load IgnoreIPs as array const ignoreIPs = data.ignoreips || []; renderIgnoreIPsTags(ignoreIPs); - // Load banaction settings document.getElementById('banaction').value = data.banaction || 'nftables-multiport'; document.getElementById('banactionAllports').value = data.banactionAllports || 'nftables-allports'; @@ -202,10 +146,13 @@ function loadSettings() { .finally(() => showLoading(false)); } +// ========================================================================= +// Save Settings +// ========================================================================= + function saveSettings(event) { event.preventDefault(); - // Validate all fields before submitting if (!validateAllSettings()) { showToast('Please fix validation errors before saving', 'error'); return; @@ -233,7 +180,6 @@ function saveSettings(event) { 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; @@ -283,8 +229,6 @@ function saveSettings(event) { 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"]); @@ -302,6 +246,35 @@ function saveSettings(event) { .finally(() => showLoading(false)); } +// ========================================================================= +// Email Settings +// ========================================================================= + +function updateEmailFieldsState() { + const emailAlertsForBans = document.getElementById('emailAlertsForBans').checked; + const emailAlertsForUnbans = document.getElementById('emailAlertsForUnbans').checked; + const emailEnabled = emailAlertsForBans || emailAlertsForUnbans; + + const emailFields = [ + document.getElementById('destEmail'), + document.getElementById('smtpHost'), + document.getElementById('smtpPort'), + document.getElementById('smtpUsername'), + document.getElementById('smtpPassword'), + document.getElementById('smtpFrom'), + document.getElementById('smtpAuthMethod'), + document.getElementById('smtpUseTLS'), + document.getElementById('smtpInsecureSkipVerify'), + document.getElementById('sendTestEmailBtn') + ]; + + emailFields.forEach(field => { + if (field) { + field.disabled = !emailEnabled; + } + }); +} + function sendTestEmail() { showLoading(true); @@ -321,6 +294,10 @@ function sendTestEmail() { .finally(() => showLoading(false)); } +// ========================================================================= +// Advanced Actions +// ========================================================================= + function applyAdvancedActionsSettings(cfg) { cfg = cfg || {}; const enabledEl = document.getElementById('advancedActionsEnabled'); @@ -405,6 +382,10 @@ function updateAdvancedIntegrationFields() { document.getElementById('advancedOPNsenseFields').classList.toggle('hidden', selected !== 'opnsense'); } +// ========================================================================= +// Permanent Block Log +// ========================================================================= + function loadPermanentBlockLog() { fetch('/api/advanced-actions/blocks') .then(res => res.json()) @@ -498,6 +479,10 @@ function refreshPermanentBlockLog() { loadPermanentBlockLog(); } +// ========================================================================= +// Advanced Test +// ========================================================================= + function openAdvancedTestModal() { document.getElementById('advancedTestIP').value = ''; openModal('advancedTestModal'); @@ -520,9 +505,7 @@ function submitAdvancedTest(action) { 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); + showToast(data.message || 'Action completed', data.info ? 'info' : 'success'); loadPermanentBlockLog(); } }) @@ -556,13 +539,15 @@ function advancedUnblockIP(ip, event) { .catch(err => showToast('Failed to remove IP: ' + err, 'error')); } -// Initialize advanced integration select listener +// ========================================================================= +// Misc +// ========================================================================= + const advancedIntegrationSelect = document.getElementById('advancedIntegrationSelect'); if (advancedIntegrationSelect) { advancedIntegrationSelect.addEventListener('change', updateAdvancedIntegrationFields); } -// Toggle callback secret visibility function toggleCallbackSecretVisibility() { const input = document.getElementById('callbackSecret'); const link = document.getElementById('toggleCallbackSecretLink'); @@ -574,3 +559,13 @@ function toggleCallbackSecretVisibility() { link.textContent = isPassword ? 'hide secret' : 'show secret'; } +function onGeoIPProviderChange(provider) { + const dbPathContainer = document.getElementById('geoipDatabasePathContainer'); + if (dbPathContainer) { + if (provider === 'maxmind') { + dbPathContainer.style.display = 'block'; + } else { + dbPathContainer.style.display = 'none'; + } + } +}