Files
fail2ban-ui/pkg/web/templates/index.html

3306 lines
148 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Fail2ban UI - A Swiss made, management interface for Fail2ban.
Copyright (C) 2025 Swissmakers GmbH
Licensed under the GNU General Public License, Version 3 (GPL-3.0)
You may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.gnu.org/licenses/gpl-3.0.en.html
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title data-i18n="page.title">Fail2ban UI Dashboard</title>
<!-- Tailwind CSS - Try local first, fallback to CDN for development -->
<link rel="stylesheet" href="/static/tailwind.css" onerror="
console.warn('Local Tailwind CSS not found, using CDN. For production, build Tailwind CSS. See README.md for instructions.');
var script = document.createElement('script');
script.src = 'https://cdn.tailwindcss.com';
document.head.appendChild(script);
this.onerror = null;
">
<!-- Font Awesome for icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Select2 CSS -->
<link href="https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/css/select2.min.css" rel="stylesheet" />
<style>
/* Loading overlay styling */
#loading-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 9999;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
opacity: 0;
transition: opacity 0.4s ease;
}
#loading-overlay.show {
display: flex;
opacity: 1;
}
/* Restart banner */
#restartBanner {
display: none;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Custom select2 styling to match Tailwind */
.select2-container--default .select2-selection--multiple {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
min-height: 42px;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice {
background-color: #3b82f6;
border: 1px solid #3b82f6;
color: white;
border-radius: 0.25rem;
padding: 0 0.5rem;
}
.select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
color: white;
margin-right: 0.25rem;
}
/* Custom modal styling */
.modal-content {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
body.modal-open {
overflow: hidden !important;
}
/* Custom tooltip styling */
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .tooltip-text {
visibility: hidden;
width: 200px;
background-color: #333;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px;
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
margin-left: -100px;
opacity: 0;
transition: opacity 0.3s;
}
.tooltip:hover .tooltip-text {
visibility: visible;
opacity: 1;
}
/* Custom mark styling for search highlights */
mark {
background-color: #fef08a;
padding: 0.1em 0em 0.1em 0.2em;
border-radius: 0.25em;
}
/* Toast notifications */
#toast-container {
position: fixed;
top: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 10000;
pointer-events: none;
}
.toast {
min-width: 240px;
max-width: 360px;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
color: #fff;
font-weight: 500;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
opacity: 0;
transform: translateY(-6px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast-success {
background-color: #047857;
}
.toast-error {
background-color: #b91c1c;
}
.toast-info {
background-color: #1d4ed8;
}
</style>
</head>
<body class="bg-gray-50 overflow-y-scroll">
<!-- Loading Overlay -->
<div id="loading-overlay" class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50 backdrop-blur-sm">
<div class="h-12 w-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
<!-- Restart Banner -->
<div id="restartBanner" class="bg-yellow-400 text-gray-900 p-3 text-center">
<div class="max-w-7xl mx-auto flex flex-col md:flex-row items-center justify-center gap-4">
<strong data-i18n="restart_banner.message">Fail2ban configuration changed! To apply the changes, please: </strong>
<button class="bg-gray-800 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors" onclick="restartFail2ban()" data-i18n="restart_banner.button">Restart Service</button>
</div>
</div>
<div id="toast-container"></div>
<!-- ******************************************************************* -->
<!-- Navigation START -->
<!-- ******************************************************************* -->
<nav class="bg-blue-600 text-white shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-xl font-bold">Fail2ban UI</span>
</div>
</div>
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<a href="#" onclick="showSection('dashboardSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.dashboard">Dashboard</a>
<a href="#" onclick="showSection('filterSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.filter_debug">Filter Debug</a>
<a href="#" onclick="showSection('settingsSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.settings">Settings</a>
</div>
</div>
<div class="md:hidden">
<button type="button" class="inline-flex items-center justify-center p-2 rounded-md text-white hover:text-white hover:bg-blue-700 focus:outline-none" onclick="toggleMobileMenu()">
<svg class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile menu -->
<div id="mobileMenu" class="hidden md:hidden">
<div class="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<a href="#" onclick="showSection('dashboardSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.dashboard">Dashboard</a>
<a href="#" onclick="showSection('filterSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.filter_debug">Filter Debug</a>
<a href="#" onclick="showSection('settingsSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.settings">Settings</a>
</div>
</div>
</nav>
<!-- ************************ Navigation END *************************** -->
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- ******************************************************************* -->
<!-- Dashboard Page START -->
<!-- ******************************************************************* -->
<div id="dashboardSection">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800" data-i18n="dashboard.title">Dashboard</h1>
<p id="currentServerSubtitle" class="text-sm text-gray-500 mt-1"></p>
</div>
<div class="flex flex-col sm:flex-row sm:items-center gap-3">
<div id="serverSelectorContainer" class="min-w-[220px]"></div>
<div class="text-sm text-gray-500 sm:ml-2">
<span data-i18n="dashboard.external_ip">Your ext. IP:</span>
<span id="external-ip" class="font-medium text-blue-600 hover:underline cursor-pointer">Loading…</span>
</div>
<div class="flex flex-col sm:flex-row gap-3">
<button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors flex items-center gap-2"
onclick="openServerManager()">
<i class="fas fa-network-wired"></i>
<span data-i18n="dashboard.manage_servers">Manage Servers</span>
</button>
<button class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors flex items-center gap-2"
onclick="openManageJailsModal()">
<i class="fas fa-cog"></i>
<span data-i18n="dashboard.manage_jails">Manage Jails</span>
</button>
</div>
</div>
</div>
<div id="dashboard"></div> <!-- Dynamic content from the API -->
</div>
<!-- ********************** Dashboard Page END ************************* -->
<!-- ******************************************************************* -->
<!-- Filter-Debug Page START -->
<!-- ******************************************************************* -->
<div id="filterSection" class="hidden">
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="filter_debug.title">Filter Debug</h2>
<div id="filterNotice" class="hidden mb-4 text-sm text-yellow-700 bg-yellow-100 border border-yellow-200 rounded px-4 py-3"></div>
<div class="bg-white rounded-lg shadow p-6 mb-6">
<!-- Dropdown of available jail/filters -->
<div class="mb-4">
<label for="filterSelect" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="filter_debug.select_filter">Select a Filter</label>
<select id="filterSelect" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"></select>
</div>
<!-- Textarea for log lines to test -->
<div class="mb-4">
<label for="logLinesTextarea" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="filter_debug.log_lines">Log Lines</label>
<textarea id="logLinesTextarea" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40"
data-i18n-placeholder="filter_debug.log_lines_placeholder" placeholder="Enter log lines here..."></textarea>
</div>
<button class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors" onclick="testSelectedFilter()" data-i18n="filter_debug.test_filter">Test Filter</button>
</div>
<div id="testResults" class="hidden bg-gray-900 rounded-lg shadow p-6 text-white font-mono text-sm"></div>
</div>
<!-- ********************* Filter-Debug Page END *********************** -->
<!-- ******************************************************************* -->
<!-- Settings Page START -->
<!-- ******************************************************************* -->
<div id="settingsSection" class="hidden">
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="settings.title">Settings</h2>
<form onsubmit="saveSettings(event)" class="space-y-6">
<!-- General Settings Group -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.general">General Settings</h3>
<!-- Language Selection -->
<div class="mb-4">
<label for="languageSelect" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.language">Language</label>
<select id="languageSelect" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="it">Italiano</option>
<option value="de_ch">Schwiizerdütsch</option>
</select>
</div>
<!-- Fail2Ban UI Port (server) -->
<div class="mb-4">
<label for="uiPort" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.server_port">Server Port</label>
<input type="number" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="uiPort"
data-i18n-placeholder="settings.server_port_placeholder" placeholder="e.g., 8080" required min="80" max="65535" />
</div>
<div class="mb-4">
<label for="callbackURL" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.callback_url">Fail2ban Callback URL</label>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="callbackURL"
data-i18n-placeholder="settings.callback_url_placeholder" placeholder="http://127.0.0.1:8080" />
</div>
<!-- Debug Log Output -->
<div class="flex items-center">
<input type="checkbox" id="debugMode" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out">
<label for="debugMode" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_debug">Enable Debug Log</label>
</div>
</div>
<!-- Alert Settings Group -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.alert">Alert Settings</h3>
<div class="mb-4">
<label for="destEmail" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.destination_email">Destination Email (Alerts Receiver)</label>
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="destEmail"
data-i18n-placeholder="settings.destination_email_placeholder" placeholder="alerts@swissmakers.ch" />
</div>
<div class="mb-4">
<label for="alertCountries" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.alert_countries">Alert Countries</label>
<p class="text-sm text-gray-500 mb-2" data-i18n="settings.alert_countries_description">
Choose the countries for which you want to receive email alerts when a block is triggered.
</p>
<select id="alertCountries" class="w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" multiple>
<option value="ALL">ALL (Every Country)</option>
<option value="AF">Afghanistan (AF)</option>
<option value="AL">Albania (AL)</option>
<option value="DZ">Algeria (DZ)</option>
<option value="AD">Andorra (AD)</option>
<option value="AO">Angola (AO)</option>
<option value="AG">Antigua and Barbuda (AG)</option>
<option value="AR">Argentina (AR)</option>
<option value="AM">Armenia (AM)</option>
<option value="AU">Australia (AU)</option>
<option value="AT">Austria (AT)</option>
<option value="AZ">Azerbaijan (AZ)</option>
<option value="BS">Bahamas (BS)</option>
<option value="BH">Bahrain (BH)</option>
<option value="BD">Bangladesh (BD)</option>
<option value="BB">Barbados (BB)</option>
<option value="BY">Belarus (BY)</option>
<option value="BE">Belgium (BE)</option>
<option value="BZ">Belize (BZ)</option>
<option value="BJ">Benin (BJ)</option>
<option value="BT">Bhutan (BT)</option>
<option value="BO">Bolivia (BO)</option>
<option value="BA">Bosnia and Herzegovina (BA)</option>
<option value="BW">Botswana (BW)</option>
<option value="BR">Brazil (BR)</option>
<option value="BN">Brunei (BN)</option>
<option value="BG">Bulgaria (BG)</option>
<option value="BF">Burkina Faso (BF)</option>
<option value="BI">Burundi (BI)</option>
<option value="CV">Cabo Verde (CV)</option>
<option value="KH">Cambodia (KH)</option>
<option value="CM">Cameroon (CM)</option>
<option value="CA">Canada (CA)</option>
<option value="CF">Central African Republic (CF)</option>
<option value="TD">Chad (TD)</option>
<option value="CL">Chile (CL)</option>
<option value="CN">China (CN)</option>
<option value="CO">Colombia (CO)</option>
<option value="KM">Comoros (KM)</option>
<option value="CG">Congo - Brazzaville (CG)</option>
<option value="CD">Congo - Kinshasa (CD)</option>
<option value="CR">Costa Rica (CR)</option>
<option value="CI">Côte d'Ivoire (CI)</option>
<option value="HR">Croatia (HR)</option>
<option value="CU">Cuba (CU)</option>
<option value="CY">Cyprus (CY)</option>
<option value="CZ">Czechia (CZ)</option>
<option value="DK">Denmark (DK)</option>
<option value="DJ">Djibouti (DJ)</option>
<option value="DM">Dominica (DM)</option>
<option value="DO">Dominican Republic (DO)</option>
<option value="EC">Ecuador (EC)</option>
<option value="EG">Egypt (EG)</option>
<option value="SV">El Salvador (SV)</option>
<option value="GQ">Equatorial Guinea (GQ)</option>
<option value="ER">Eritrea (ER)</option>
<option value="EE">Estonia (EE)</option>
<option value="SZ">Eswatini (SZ)</option>
<option value="ET">Ethiopia (ET)</option>
<option value="FJ">Fiji (FJ)</option>
<option value="FI">Finland (FI)</option>
<option value="FR">France (FR)</option>
<option value="GA">Gabon (GA)</option>
<option value="GM">Gambia (GM)</option>
<option value="GE">Georgia (GE)</option>
<option value="DE">Germany (DE)</option>
<option value="GH">Ghana (GH)</option>
<option value="GR">Greece (GR)</option>
<option value="GD">Grenada (GD)</option>
<option value="GT">Guatemala (GT)</option>
<option value="GN">Guinea (GN)</option>
<option value="GW">Guinea-Bissau (GW)</option>
<option value="GY">Guyana (GY)</option>
<option value="HT">Haiti (HT)</option>
<option value="HN">Honduras (HN)</option>
<option value="HU">Hungary (HU)</option>
<option value="IS">Iceland (IS)</option>
<option value="IN">India (IN)</option>
<option value="ID">Indonesia (ID)</option>
<option value="IR">Iran (IR)</option>
<option value="IQ">Iraq (IQ)</option>
<option value="IE">Ireland (IE)</option>
<option value="IL">Israel (IL)</option>
<option value="IT">Italy (IT)</option>
<option value="JM">Jamaica (JM)</option>
<option value="JP">Japan (JP)</option>
<option value="JO">Jordan (JO)</option>
<option value="KZ">Kazakhstan (KZ)</option>
<option value="KE">Kenya (KE)</option>
<option value="KI">Kiribati (KI)</option>
<option value="KP">North Korea (KP)</option>
<option value="KR">South Korea (KR)</option>
<option value="KW">Kuwait (KW)</option>
<option value="KG">Kyrgyzstan (KG)</option>
<option value="LA">Laos (LA)</option>
<option value="LV">Latvia (LV)</option>
<option value="LB">Lebanon (LB)</option>
<option value="LS">Lesotho (LS)</option>
<option value="LR">Liberia (LR)</option>
<option value="LY">Libya (LY)</option>
<option value="LI">Liechtenstein (LI)</option>
<option value="LT">Lithuania (LT)</option>
<option value="LU">Luxembourg (LU)</option>
<option value="MG">Madagascar (MG)</option>
<option value="MW">Malawi (MW)</option>
<option value="MY">Malaysia (MY)</option>
<option value="MV">Maldives (MV)</option>
<option value="ML">Mali (ML)</option>
<option value="MT">Malta (MT)</option>
<option value="MH">Marshall Islands (MH)</option>
<option value="MR">Mauritania (MR)</option>
<option value="MU">Mauritius (MU)</option>
<option value="MX">Mexico (MX)</option>
<option value="FM">Micronesia (FM)</option>
<option value="LOTR">Middle-earth (LOTR)</option>
<option value="MD">Moldova (MD)</option>
<option value="MC">Monaco (MC)</option>
<option value="MN">Mongolia (MN)</option>
<option value="ME">Montenegro (ME)</option>
<option value="MA">Morocco (MA)</option>
<option value="MZ">Mozambique (MZ)</option>
<option value="MM">Myanmar (MM)</option>
<option value="NA">Namibia (NA)</option>
<option value="NR">Nauru (NR)</option>
<option value="NP">Nepal (NP)</option>
<option value="NL">Netherlands (NL)</option>
<option value="NZ">New Zealand (NZ)</option>
<option value="NI">Nicaragua (NI)</option>
<option value="NE">Niger (NE)</option>
<option value="NG">Nigeria (NG)</option>
<option value="NO">Norway (NO)</option>
<option value="OM">Oman (OM)</option>
<option value="PK">Pakistan (PK)</option>
<option value="PW">Palau (PW)</option>
<option value="PS">Palestine (PS)</option>
<option value="PA">Panama (PA)</option>
<option value="PG">Papua New Guinea (PG)</option>
<option value="PY">Paraguay (PY)</option>
<option value="PE">Peru (PE)</option>
<option value="PH">Philippines (PH)</option>
<option value="PL">Polland (PL)</option>
<option value="PT">Portugal (PT)</option>
<option value="QA">Qatar (QA)</option>
<option value="RO">Romania (RO)</option>
<option value="RU">Russia (RU)</option>
<option value="RW">Rwanda (RW)</option>
<option value="KN">Saint Kitts and Nevis (KN)</option>
<option value="LC">Saint Lucia (LC)</option>
<option value="VC">Saint Vincent and the Grenadines (VC)</option>
<option value="WS">Samoa (WS)</option>
<option value="SM">San Marino (SM)</option>
<option value="ST">Sao Tome and Principe (ST)</option>
<option value="SA">Saudi Arabia (SA)</option>
<option value="SN">Senegal (SN)</option>
<option value="RS">Serbia (RS)</option>
<option value="SC">Seychelles (SC)</option>
<option value="SL">Sierra Leone (SL)</option>
<option value="SG">Singapore (SG)</option>
<option value="SK">Slovakia (SK)</option>
<option value="SI">Slovenia (SI)</option>
<option value="SB">Solomon Islands (SB)</option>
<option value="SO">Somalia (SO)</option>
<option value="ZA">South Africa (ZA)</option>
<option value="SS">South Sudan (SS)</option>
<option value="ES">Spain (ES)</option>
<option value="LK">Sri Lanka (LK)</option>
<option value="SD">Sudan (SD)</option>
<option value="SR">Suriname (SR)</option>
<option value="SE">Sweden (SE)</option>
<option value="CH">Switzerland (CH)</option>
<option value="SY">Syria (SY)</option>
<option value="TW">Taiwan (TW)</option>
<option value="TJ">Tajikistan (TJ)</option>
<option value="TZ">Tanzania (TZ)</option>
<option value="TH">Thailand (TH)</option>
<option value="TL">Timor-Leste (TL)</option>
<option value="TG">Togo (TG)</option>
<option value="TO">Tonga (TO)</option>
<option value="TT">Trinidad and Tobago (TT)</option>
<option value="TN">Tunisia (TN)</option>
<option value="TR">Turkey (TR)</option>
<option value="TM">Turkmenistan (TM)</option>
<option value="TV">Tuvalu (TV)</option>
<option value="UG">Uganda (UG)</option>
<option value="UA">Ukraine (UA)</option>
<option value="AE">United Arab Emirates (AE)</option>
<option value="GB">United Kingdom (GB)</option>
<option value="US">United States (US)</option>
<option value="UY">Uruguay (UY)</option>
<option value="UZ">Uzbekistan (UZ)</option>
<option value="VU">Vanuatu (VU)</option>
<option value="VE">Venezuela (VE)</option>
<option value="VN">Vietnam (VN)</option>
<option value="YE">Yemen (YE)</option>
<option value="ZM">Zambia (ZM)</option>
<option value="ZW">Zimbabwe (ZW)</option>
</select>
</div>
</div>
<!-- SMTP Configuration Group -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.smtp">SMTP Configuration</h3>
<div class="mb-4">
<label for="smtpHost" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_host">SMTP Host</label>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpHost"
data-i18n-placeholder="settings.smtp_host_placeholder" placeholder="e.g., smtp.gmail.com" required />
</div>
<div class="mb-4">
<label for="smtpPort" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_port">SMTP Port</label>
<select id="smtpPort" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="587" selected>587 (Recommended - STARTTLS)</option>
<option value="465" disabled>465 (Not Supported)</option>
</select>
</div>
<div class="mb-4">
<label for="smtpUsername" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_username">SMTP Username</label>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpUsername"
data-i18n-placeholder="settings.smtp_username_placeholder" placeholder="e.g., user@example.com" required />
</div>
<div class="mb-4">
<label for="smtpPassword" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_password">SMTP Password</label>
<input type="password" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpPassword"
data-i18n-placeholder="settings.smtp_password_placeholder" placeholder="Enter SMTP Password" required />
</div>
<div class="mb-4">
<label for="smtpFrom" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_sender">Sender Email</label>
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpFrom"
data-i18n-placeholder="settings.smtp_sender_placeholder" placeholder="noreply@swissmakers.ch" required />
</div>
<div class="flex items-center mb-4">
<input type="checkbox" id="smtpUseTLS" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out">
<label for="smtpUseTLS" class="ml-2 block text-sm text-gray-700" data-i18n="settings.smtp_tls">Use TLS (Recommended)</label>
</div>
<button type="button" class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors" onclick="sendTestEmail()" data-i18n="settings.send_test_email">Send Test Email</button>
</div>
<!-- Fail2Ban Configuration Group -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.fail2ban">Fail2Ban Configuration</h3>
<!-- Bantime Increment -->
<div class="flex items-center mb-4">
<input type="checkbox" id="bantimeIncrement" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
<label for="bantimeIncrement" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_bantime_increment">Enable Bantime Increment</label>
</div>
<!-- Bantime -->
<div class="mb-4">
<label for="banTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_bantime">Default Bantime</label>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="banTime"
data-i18n-placeholder="settings.default_bantime_placeholder" placeholder="e.g., 48h" />
</div>
<!-- Findtime -->
<div class="mb-4">
<label for="findTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_findtime">Default Findtime</label>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="findTime"
data-i18n-placeholder="settings.default_findtime_placeholder" placeholder="e.g., 30m" />
</div>
<!-- Max Retry -->
<div class="mb-4">
<label for="maxRetry" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_max_retry">Default Max Retry</label>
<input type="number" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="maxRetry"
data-i18n-placeholder="settings.default_max_retry_placeholder" placeholder="Enter maximum retries" />
</div>
<!-- Ignore IPs -->
<div class="mb-4">
<label for="ignoreIP" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.ignore_ips">Ignore IPs</label>
<textarea class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="ignoreIP" rows="2"
data-i18n-placeholder="settings.ignore_ips_placeholder" placeholder="IPs to ignore, separated by spaces"></textarea>
</div>
</div>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors" data-i18n="settings.save">Save</button>
</form>
</div>
<!-- *********************** Settings Page END ************************* -->
</main>
<!-- Footer -->
<footer class="bg-gray-100 py-4">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-600 text-sm">
<p class="mb-0">
&copy; <a href="https://swissmakers.ch" target="_blank" class="text-blue-600 hover:text-blue-800">Swissmakers GmbH</a>
-
<a href="https://github.com/swissmakers/fail2ban-ui" target="_blank" class="text-blue-600 hover:text-blue-800">GitHub</a>
</p>
</div>
</footer>
<!-- ******************************************************************* -->
<!-- Modal Templates START -->
<!-- ******************************************************************* -->
<!-- Jail Config Modal -->
<div id="jailConfigModal" class="hidden fixed inset-0 overflow-hidden" style="z-index: 60;">
<div class="flex items-center justify-center min-h-screen pt-4 px-2 sm:px-4 pb-20 text-center">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="relative inline-block align-bottom bg-white rounded-lg text-left shadow-xl transform transition-all my-4 sm:my-8 align-middle w-full max-w-full max-h-screen overflow-y-auto" style="max-width: 90vw; max-height: calc(100vh - 2rem);">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<span data-i18n="modal.filter_config">Filter Config:</span> <span id="modalJailName"></span>
</h3>
<div class="mt-4">
<textarea id="jailConfigTextarea"
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-96 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
data-extension-ignore="true"
data-icloud-keychain-ignore="true"
data-safari-autofill="false"
role="textbox"
aria-label="Filter configuration editor"
name="filter-config-editor"
inputmode="text"
style="caret-color: #ffffff; line-height: 1.5; tab-size: 2; width: 100%; min-width: 100%; max-width: 100%; box-sizing: border-box; -webkit-appearance: none; appearance: none;"
wrap="off"
onfocus="preventExtensionInterference(this);"></textarea>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm" onclick="saveJailConfig()" data-i18n="modal.save">Save</button>
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('jailConfigModal')" data-i18n="modal.cancel">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Manage Jails Modal -->
<div id="manageJailsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="modal.manage_jails_title">Manage Jails</h3>
<div class="mt-4">
<!-- Dynamically filled list of jails with toggle switches -->
<div id="jailsList" class="divide-y divide-gray-200"></div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm" onclick="saveManageJails()" data-i18n="modal.save">Save Changes</button>
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('manageJailsModal')" data-i18n="modal.cancel">Cancel</button>
</div>
</div>
</div>
</div>
<!-- Server Manager Modal -->
<div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-5xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-6">
<div class="flex flex-col gap-6">
<div>
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="servers.modal.title">Manage Fail2ban Servers</h3>
<p class="mt-1 text-sm text-gray-500" data-i18n="servers.modal.description">
Register remote Fail2ban instances and choose how the UI connects to them.
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h4 class="text-md font-semibold text-gray-800 mb-3" data-i18n="servers.modal.list_title">Registered Servers</h4>
<div id="serverManagerList" class="space-y-3 max-h-96 overflow-y-auto pr-1"></div>
<div id="serverManagerListEmpty" class="text-sm text-gray-500 hidden" data-i18n="servers.modal.list_empty">
No servers configured yet. Add your first Fail2ban server using the form on the right.
</div>
</div>
<div>
<h4 class="text-md font-semibold text-gray-800 mb-3" data-i18n="servers.modal.form_title">Add or Update Server</h4>
<form id="serverForm" class="space-y-4" onsubmit="submitServerForm(event)">
<div>
<label for="serverName" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.name">Display Name</label>
<input type="text" id="serverName" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required data-i18n-placeholder="servers.form.name_placeholder" placeholder="My Fail2ban server">
</div>
<div>
<label for="serverType" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.type">Connection Type</label>
<select id="serverType" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" required onchange="onServerTypeChange(this.value)">
<option value="local" data-i18n="servers.type.local">Local (same host)</option>
<option value="ssh" data-i18n="servers.type.ssh">SSH</option>
<option value="agent" data-i18n="servers.type.agent">API Agent</option>
</select>
</div>
<div data-server-fields="ssh agent">
<label for="serverHost" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.host">Hostname / IP</label>
<input type="text" id="serverHost" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.host_placeholder" placeholder="fail2ban.example.com">
</div>
<div data-server-fields="ssh agent">
<label for="serverPort" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.port">Port</label>
<input type="number" id="serverPort" min="1" max="65535" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.port_placeholder" placeholder="22">
</div>
<div data-server-fields="local ssh">
<label for="serverSocket" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.socket_path">Fail2ban Socket Path</label>
<input type="text" id="serverSocket" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.socket_path_placeholder" placeholder="/var/run/fail2ban/fail2ban.sock">
</div>
<div data-server-fields="local">
<label for="serverLogPath" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.log_path">Fail2ban Log Path</label>
<input type="text" id="serverLogPath" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.log_path_placeholder" placeholder="/var/log/fail2ban.log">
</div>
<div data-server-fields="local">
<label for="serverHostname" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.hostname">Server Hostname</label>
<input type="text" id="serverHostname" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.hostname_placeholder" placeholder="optional">
</div>
<div data-server-fields="ssh">
<label for="serverSSHUser" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.ssh_user">SSH User</label>
<input type="text" id="serverSSHUser" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.ssh_user_placeholder" placeholder="root">
</div>
<div data-server-fields="ssh">
<label for="serverSSHKey" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.ssh_key">SSH Private Key Path</label>
<input type="text" id="serverSSHKey" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.ssh_key_placeholder" placeholder="~/.ssh/id_rsa">
</div>
<div data-server-fields="ssh">
<label for="serverSSHKeySelect" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.select_key">Select Private Key</label>
<select id="serverSSHKeySelect" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" data-i18n="servers.form.select_key_placeholder">Manual entry</option>
</select>
</div>
<div data-server-fields="agent">
<label for="serverAgentUrl" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.agent_url">Agent URL</label>
<input type="url" id="serverAgentUrl" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.agent_url_placeholder" placeholder="https://host:9443">
</div>
<div data-server-fields="agent">
<label for="serverAgentSecret" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.agent_secret">Agent Secret</label>
<input type="text" id="serverAgentSecret" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.agent_secret_placeholder" placeholder="shared secret token">
</div>
<div>
<label for="serverTags" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.tags">Tags</label>
<input type="text" id="serverTags" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.tags_placeholder" placeholder="comma,separated,tags">
</div>
<div class="flex items-center">
<input type="checkbox" id="serverDefault" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
<label for="serverDefault" class="ml-2 text-sm text-gray-700" data-i18n="servers.form.set_default">Set as default server</label>
</div>
<div class="flex items-center">
<input type="checkbox" id="serverEnabled" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
<label for="serverEnabled" class="ml-2 text-sm text-gray-700" data-i18n="servers.form.enabled">Enable connector</label>
</div>
<input type="hidden" id="serverId">
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors" data-i18n="servers.form.submit">Save Server</button>
<button type="button" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition-colors" onclick="resetServerForm()" data-i18n="servers.form.reset">Reset</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" class="w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('serverManagerModal')" data-i18n="modal.close">Close</button>
</div>
</div>
</div>
</div>
<!-- Whois Modal -->
<div id="whoisModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<span data-i18n="logs.modal.whois_title">Whois Information</span> - <span id="whoisModalIP"></span>
</h3>
<div class="mt-4">
<pre id="whoisModalContent" class="w-full border border-gray-300 rounded-md px-3 py-2 bg-gray-900 text-white font-mono text-xs overflow-x-auto" style="max-height: 70vh; white-space: pre-wrap; word-wrap: break-word;"></pre>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" class="w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('whoisModal')" data-i18n="modal.close">Close</button>
</div>
</div>
</div>
</div>
<!-- Logs Modal -->
<div id="logsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-2">
<span data-i18n="logs.modal.logs_title">Logs</span> - <span id="logsModalIP"></span>
</h3>
<p class="text-sm text-gray-600 mb-4">
<span data-i18n="logs.modal.jail">Jail:</span> <span id="logsModalJail" class="font-semibold"></span>
</p>
<div class="mt-4">
<pre id="logsModalContent" class="w-full border border-gray-300 rounded-md px-3 py-2 bg-gray-900 text-white font-mono text-xs overflow-x-auto" style="max-height: 70vh; white-space: pre-wrap; word-wrap: break-word;"></pre>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" class="w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('logsModal')" data-i18n="modal.close">Close</button>
</div>
</div>
</div>
</div>
<!-- Ban Insights Modal -->
<div id="banInsightsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div class="fixed inset-0 transition-opacity" aria-hidden="true">
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
</div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="logs.modal.insights_title">Ban Insights</h3>
<p class="mt-1 text-sm text-gray-500" data-i18n="logs.modal.insights_description">Country distribution and recurring offenders.</p>
<div class="mt-6">
<h4 class="text-md font-semibold text-gray-800 mb-2" data-i18n="logs.modal.insights_countries">Bans by country</h4>
<div id="countryStatsContainer" class="max-h-64 overflow-y-auto divide-y divide-gray-200"></div>
</div>
<div class="mt-6">
<h4 class="text-md font-semibold text-gray-800 mb-2" data-i18n="logs.modal.insights_recurring">Recurring IPs</h4>
<div id="recurringIPsContainer" class="max-h-64 overflow-y-auto divide-y divide-gray-200"></div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button type="button" class="w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('banInsightsModal')" data-i18n="modal.close">Close</button>
</div>
</div>
</div>
</div>
<!-- ********************** Modal Templates END ************************ -->
<!-- jQuery (used by Select2) -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- Select2 JS -->
<script src="https://cdn.jsdelivr.net/npm/select2@4.0.13/dist/js/select2.min.js"></script>
<script>
// For information: We avoid ES6 backticks in our JS, to prevent confusion with the Go template parser.
"use strict";
// *******************************************************************
// * Init page and main-components : *
// *******************************************************************
showLoading(true);
var currentJailForConfig = null;
var serversCache = [];
var currentServerId = null;
var currentServer = null;
var latestSummary = null;
var latestSummaryError = null;
var latestBanStats = {};
var latestBanEvents = [];
var latestBanInsights = {
totals: { overall: 0, today: 0, week: 0 },
countries: [],
recurring: []
};
var latestServerInsights = null;
var banEventsFilterText = '';
var banEventsFilterCountry = 'all';
var banEventsFilterDebounce = null;
var translations = {};
var sshKeysCache = null;
function normalizeInsights(data) {
var normalized = data && typeof data === 'object' ? data : {};
if (!normalized.totals || typeof normalized.totals !== 'object') {
normalized.totals = { overall: 0, today: 0, week: 0 };
} else {
normalized.totals.overall = typeof normalized.totals.overall === 'number' ? normalized.totals.overall : 0;
normalized.totals.today = typeof normalized.totals.today === 'number' ? normalized.totals.today : 0;
normalized.totals.week = typeof normalized.totals.week === 'number' ? normalized.totals.week : 0;
}
if (!Array.isArray(normalized.countries)) {
normalized.countries = [];
}
if (!Array.isArray(normalized.recurring)) {
normalized.recurring = [];
}
return normalized;
}
function t(key, fallback) {
if (translations && Object.prototype.hasOwnProperty.call(translations, key) && translations[key]) {
return translations[key];
}
return fallback !== undefined ? fallback : key;
}
window.addEventListener('DOMContentLoaded', function() {
showLoading(true);
displayExternalIP();
Promise.all([
loadServers(),
getTranslationsSettingsOnPageload()
])
.then(function() {
updateRestartBanner();
return refreshData({ silent: true });
})
.catch(function(err) {
console.error('Initialization error:', err);
latestSummaryError = err ? err.toString() : 'failed to initialize';
renderDashboard();
})
.finally(function() {
initializeTooltips(); // Initialize tooltips after fetching and rendering
initializeSearch();
showLoading(false);
});
});
// *******************************************************************
// *******************************************************************
// * Global JavaScript Functions *
// *******************************************************************
// Toggle the loading overlay (with !important)
function showLoading(show) {
var overlay = document.getElementById('loading-overlay');
if (show) {
overlay.style.setProperty('display', 'flex', 'important');
setTimeout(() => overlay.classList.add('show'), 10);
} else {
overlay.classList.remove('show'); // Start fade-out
setTimeout(() => overlay.style.setProperty('display', 'none', 'important'), 400);
}
}
function showToast(message, type) {
var container = document.getElementById('toast-container');
if (!container || !message) return;
var toast = document.createElement('div');
var variant = type || 'info';
toast.className = 'toast toast-' + variant;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(function() {
toast.classList.add('show');
});
setTimeout(function() {
toast.classList.remove('show');
setTimeout(function() {
toast.remove();
}, 300);
}, 5000);
}
// Fetch and display own external IP for webUI
function displayExternalIP() {
fetch('https://api.ipify.org?format=json')
.then(res => res.json())
.then(data => {
document.getElementById('external-ip').textContent = data.ip;
})
.catch(() => {
document.getElementById('external-ip').textContent = 'Unavailable';
});
}
// Function to initialize tooltips
function initializeTooltips() {
const tooltips = document.querySelectorAll('[data-tooltip]');
tooltips.forEach(el => {
el.addEventListener('mouseenter', () => {
const tooltip = document.createElement('div');
tooltip.className = 'absolute z-10 bg-gray-800 text-white text-xs rounded py-1 px-2 whitespace-nowrap';
tooltip.textContent = el.getAttribute('data-tooltip');
tooltip.style.top = (el.offsetTop - 30) + 'px';
tooltip.style.left = (el.offsetLeft + (el.offsetWidth / 2) - (tooltip.offsetWidth / 2)) + 'px';
tooltip.id = 'tooltip-' + Date.now();
document.body.appendChild(tooltip);
el.setAttribute('data-tooltip-id', tooltip.id);
});
el.addEventListener('mouseleave', () => {
const tooltipId = el.getAttribute('data-tooltip-id');
if (tooltipId) {
const tooltip = document.getElementById(tooltipId);
if (tooltip) tooltip.remove();
el.removeAttribute('data-tooltip-id');
}
});
});
}
// Function to initialize the IP search
function initializeSearch() {
const ipSearch = document.getElementById("ipSearch");
if (ipSearch) {
ipSearch.addEventListener("keypress", function(event) {
const char = String.fromCharCode(event.which);
if (!/[0-9.]/.test(char)) {
event.preventDefault();
}
});
}
}
function updateRestartBanner() {
var banner = document.getElementById('restartBanner');
if (!banner) return;
if (currentServer && currentServer.restartNeeded) {
banner.style.display = 'block';
} else {
banner.style.display = 'none';
}
}
// Load dynamically the other pages when navigating in nav
function showSection(sectionId) {
// hide all sections
document.getElementById('dashboardSection').classList.add('hidden');
document.getElementById('filterSection').classList.add('hidden');
document.getElementById('settingsSection').classList.add('hidden');
// show the requested section
document.getElementById(sectionId).classList.remove('hidden');
// If it's filterSection, load filters
if (sectionId === 'filterSection') {
showFilterSection();
}
// If it's settingsSection, load settings
if (sectionId === 'settingsSection') {
loadSettings();
}
// Close navbar on mobile when clicking a menu item
document.getElementById('mobileMenu').classList.add('hidden');
}
// Toggle mobile menu
function toggleMobileMenu() {
const menu = document.getElementById('mobileMenu');
menu.classList.toggle('hidden');
}
function escapeHtml(value) {
if (value === undefined || value === null) return '';
return String(value).replace(/[&<>"']/g, function(match) {
switch (match) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
default: return '&#39;';
}
});
}
function withServerParam(url) {
if (!currentServerId) {
return url;
}
return url + (url.indexOf('?') === -1 ? '?' : '&') + 'serverId=' + encodeURIComponent(currentServerId);
}
function serverHeaders(headers) {
headers = headers || {};
if (currentServerId) {
headers['X-F2B-Server'] = currentServerId;
}
return headers;
}
function loadServers() {
return fetch('/api/servers')
.then(function(res) { return res.json(); })
.then(function(data) {
serversCache = data.servers || [];
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
if (!enabledServers.length) {
currentServerId = null;
currentServer = null;
} else {
var desired = currentServerId;
var selected = desired ? enabledServers.find(function(s) { return s.id === desired; }) : null;
if (!selected) {
var def = enabledServers.find(function(s) { return s.isDefault; });
selected = def || enabledServers[0];
}
currentServer = selected;
currentServerId = selected ? selected.id : null;
}
renderServerSelector();
renderServerSubtitle();
updateRestartBanner();
})
.catch(function(err) {
console.error('Error loading servers:', err);
serversCache = [];
currentServerId = null;
currentServer = null;
renderServerSelector();
renderServerSubtitle();
updateRestartBanner();
});
}
function renderServerSelector() {
var container = document.getElementById('serverSelectorContainer');
if (!container) return;
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
if (!serversCache.length) {
container.innerHTML = '<div class="text-sm text-red-500" data-i18n="servers.selector.empty">No servers configured</div>';
if (typeof updateTranslations === 'function') {
updateTranslations();
}
return;
}
if (!enabledServers.length) {
container.innerHTML = '<div class="text-sm text-red-500" data-i18n="servers.selector.empty">No servers configured</div>';
if (typeof updateTranslations === 'function') {
updateTranslations();
}
return;
}
var options = enabledServers.map(function(server) {
var label = escapeHtml(server.name || server.id);
var type = server.type ? (' (' + server.type.toUpperCase() + ')') : '';
return '<option value="' + escapeHtml(server.id) + '">' + label + type + '</option>';
}).join('');
container.innerHTML = ''
+ '<div class="flex flex-col">'
+ ' <label for="serverSelect" class="text-xs text-gray-500 mb-1" data-i18n="servers.selector.label">Active Server</label>'
+ ' <select id="serverSelect" class="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">'
+ options
+ ' </select>'
+ '</div>';
var select = document.getElementById('serverSelect');
if (select) {
select.value = currentServerId || '';
select.addEventListener('change', function(e) {
setCurrentServer(e.target.value);
});
}
if (typeof updateTranslations === 'function') {
updateTranslations();
}
}
function renderServerSubtitle() {
var subtitle = document.getElementById('currentServerSubtitle');
if (!subtitle) return;
if (!currentServer) {
subtitle.textContent = t('servers.selector.none', 'No server configured. Please add a Fail2ban server.');
subtitle.classList.add('text-red-500');
return;
}
subtitle.classList.remove('text-red-500');
var parts = [];
parts.push(currentServer.name || currentServer.id);
parts.push(currentServer.type ? currentServer.type.toUpperCase() : 'LOCAL');
if (currentServer.host) {
var host = currentServer.host;
if (currentServer.port) {
host += ':' + currentServer.port;
}
parts.push(host);
} else if (currentServer.hostname) {
parts.push(currentServer.hostname);
}
subtitle.textContent = parts.join(' • ');
}
function setCurrentServer(serverId) {
if (!serverId) {
currentServerId = null;
currentServer = null;
} else {
var next = serversCache.find(function(s) { return s.id === serverId && s.enabled; });
currentServer = next || null;
currentServerId = currentServer ? currentServer.id : null;
}
renderServerSelector();
renderServerSubtitle();
updateRestartBanner();
refreshData();
}
let openModalCount = 0;
function updateBodyScrollLock() {
if (openModalCount > 0) {
document.body.classList.add('modal-open');
} else {
document.body.classList.remove('modal-open');
}
}
// Close modal
function closeModal(modalId) {
var modal = document.getElementById(modalId);
if (!modal || modal.classList.contains('hidden')) {
return;
}
modal.classList.add('hidden');
openModalCount = Math.max(0, openModalCount - 1);
updateBodyScrollLock();
}
// Open modal
function openModal(modalId) {
var modal = document.getElementById(modalId);
if (!modal || !modal.classList.contains('hidden')) {
updateBodyScrollLock();
return;
}
modal.classList.remove('hidden');
openModalCount += 1;
updateBodyScrollLock();
}
// *******************************************************************
//*******************************************************************
//* Fetch data and render dashboard functions *
//*******************************************************************
function refreshData(options) {
options = options || {};
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
var summaryPromise;
if (!serversCache.length || !enabledServers.length || !currentServerId) {
latestSummary = null;
latestSummaryError = null;
summaryPromise = Promise.resolve();
} else {
summaryPromise = fetchSummaryData();
}
if (!options.silent) {
showLoading(true);
}
return Promise.all([
summaryPromise,
fetchBanStatisticsData(),
fetchBanEventsData(),
fetchBanInsightsData()
])
.then(function() {
renderDashboard();
})
.catch(function(err) {
console.error('Error refreshing data:', err);
latestSummaryError = err ? err.toString() : 'Unknown error';
renderDashboard();
})
.finally(function() {
if (!options.silent) {
showLoading(false);
}
});
}
function fetchSummaryData() {
return fetch(withServerParam('/api/summary'))
.then(function(res) { return res.json(); })
.then(function(data) {
if (data && !data.error) {
latestSummary = data;
latestSummaryError = null;
} else {
latestSummary = null;
latestSummaryError = data && data.error ? data.error : t('dashboard.errors.summary_failed', 'Failed to load summary from server.');
}
})
.catch(function(err) {
latestSummary = null;
latestSummaryError = err ? err.toString() : 'Unknown error';
});
}
function fetchBanStatisticsData() {
return fetch('/api/events/bans/stats')
.then(function(res) { return res.json(); })
.then(function(data) {
latestBanStats = data && data.counts ? data.counts : {};
})
.catch(function(err) {
console.error('Error fetching ban statistics:', err);
latestBanStats = latestBanStats || {};
});
}
function fetchBanEventsData() {
return fetch('/api/events/bans?limit=200')
.then(function(res) { return res.json(); })
.then(function(data) {
latestBanEvents = data && data.events ? data.events : [];
})
.catch(function(err) {
console.error('Error fetching ban events:', err);
latestBanEvents = latestBanEvents || [];
});
}
function fetchBanInsightsData() {
var sevenDaysAgo = new Date(Date.now() - (7 * 24 * 60 * 60 * 1000)).toISOString();
var sinceQuery = '?since=' + encodeURIComponent(sevenDaysAgo);
var globalPromise = fetch('/api/events/bans/insights' + sinceQuery)
.then(function(res) { return res.json(); })
.then(function(data) {
latestBanInsights = normalizeInsights(data);
})
.catch(function(err) {
console.error('Error fetching ban insights:', err);
if (!latestBanInsights) {
latestBanInsights = normalizeInsights(null);
}
});
var serverPromise;
if (currentServerId) {
serverPromise = fetch(withServerParam('/api/events/bans/insights' + sinceQuery))
.then(function(res) { return res.json(); })
.then(function(data) {
latestServerInsights = normalizeInsights(data);
})
.catch(function(err) {
console.error('Error fetching server-specific ban insights:', err);
latestServerInsights = null;
});
} else {
latestServerInsights = null;
serverPromise = Promise.resolve();
}
return Promise.all([globalPromise, serverPromise]);
}
function formatDateTime(value) {
if (!value) return '';
var date = new Date(value);
if (isNaN(date.getTime())) {
return value;
}
// Format as "2025.11.12, 21:21:52"
var year = date.getFullYear();
var month = String(date.getMonth() + 1).padStart(2, '0');
var day = String(date.getDate()).padStart(2, '0');
var hours = String(date.getHours()).padStart(2, '0');
var minutes = String(date.getMinutes()).padStart(2, '0');
var seconds = String(date.getSeconds()).padStart(2, '0');
return year + '.' + month + '.' + day + ', ' + hours + ':' + minutes + ':' + seconds;
}
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 captureFocusState(container) {
var active = document.activeElement;
if (!active || !container || !container.contains(active)) {
return null;
}
var state = { id: active.id || null };
if (!state.id) {
return null;
}
try {
if (typeof active.selectionStart === 'number' && typeof active.selectionEnd === 'number') {
state.selectionStart = active.selectionStart;
state.selectionEnd = active.selectionEnd;
}
} catch (err) {
// Ignore selection errors for elements that do not support it.
}
return state;
}
function restoreFocusState(state) {
if (!state || !state.id) {
return;
}
var next = document.getElementById(state.id);
if (!next) {
return;
}
if (typeof next.focus === 'function') {
try {
next.focus({ preventScroll: true });
} catch (err) {
next.focus();
}
}
try {
if (typeof state.selectionStart === 'number' && typeof state.selectionEnd === 'number' && typeof next.setSelectionRange === 'function') {
next.setSelectionRange(state.selectionStart, state.selectionEnd);
}
} catch (err) {
// Element may not support setSelectionRange; ignore.
}
}
function getBanEventCountries() {
var countries = {};
latestBanEvents.forEach(function(event) {
var country = (event.country || '').trim();
var key = country.toLowerCase();
if (!countries[key]) {
countries[key] = country;
}
});
var keys = Object.keys(countries);
keys.sort();
return keys.map(function(key) {
return countries[key];
});
}
function getFilteredBanEvents() {
var text = (banEventsFilterText || '').toLowerCase();
var countryFilter = (banEventsFilterCountry || '').toLowerCase();
return latestBanEvents.filter(function(event) {
var matchesCountry = !countryFilter || countryFilter === 'all';
if (!matchesCountry) {
var eventCountryValue = (event.country || '').toLowerCase();
if (!eventCountryValue) {
eventCountryValue = '__unknown__';
}
matchesCountry = eventCountryValue === countryFilter;
}
if (!text) {
return matchesCountry;
}
var haystack = [
event.ip,
event.jail,
event.serverName,
event.hostname,
event.country
].map(function(value) {
return (value || '').toLowerCase();
});
var matchesText = haystack.some(function(value) {
return value.indexOf(text) !== -1;
});
return matchesCountry && matchesText;
});
}
function scheduleLogOverviewRender() {
if (banEventsFilterDebounce) {
clearTimeout(banEventsFilterDebounce);
}
banEventsFilterDebounce = setTimeout(function() {
renderLogOverviewSection();
banEventsFilterDebounce = null;
}, 100);
}
function updateBanEventsSearch(value) {
banEventsFilterText = value || '';
scheduleLogOverviewRender();
}
function updateBanEventsCountry(value) {
banEventsFilterCountry = value || 'all';
scheduleLogOverviewRender();
}
function getRecurringIPMap() {
var map = {};
if (latestBanInsights && Array.isArray(latestBanInsights.recurring)) {
latestBanInsights.recurring.forEach(function(stat) {
if (stat && stat.ip) {
map[stat.ip] = stat;
}
});
}
return map;
}
function renderDashboard() {
var container = document.getElementById('dashboard');
if (!container) return;
var focusState = captureFocusState(container);
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
if (!serversCache.length) {
container.innerHTML = ''
+ '<div class="bg-yellow-100 border-l-4 border-yellow-400 text-yellow-700 p-4 rounded mb-4" role="alert">'
+ ' <p class="font-semibold" data-i18n="dashboard.no_servers_title">No Fail2ban servers configured</p>'
+ ' <p class="text-sm mt-1" data-i18n="dashboard.no_servers_body">Add a server to start monitoring and controlling Fail2ban instances.</p>'
+ '</div>';
if (typeof updateTranslations === 'function') updateTranslations();
restoreFocusState(focusState);
return;
}
if (!enabledServers.length) {
container.innerHTML = ''
+ '<div class="bg-yellow-100 border-l-4 border-yellow-400 text-yellow-700 p-4 rounded mb-4" role="alert">'
+ ' <p class="font-semibold" data-i18n="dashboard.no_enabled_servers_title">No active connectors</p>'
+ ' <p class="text-sm mt-1" data-i18n="dashboard.no_enabled_servers_body">Enable the local connector or register a remote Fail2ban server to see live data.</p>'
+ '</div>';
if (typeof updateTranslations === 'function') updateTranslations();
restoreFocusState(focusState);
return;
}
var summary = latestSummary;
var html = '';
if (latestSummaryError) {
html += ''
+ '<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4">'
+ escapeHtml(latestSummaryError)
+ '</div>';
}
if (!summary) {
html += ''
+ '<div class="bg-white rounded-lg shadow p-6 mb-6">'
+ ' <p class="text-gray-500" data-i18n="dashboard.loading_summary">Loading summary data…</p>'
+ '</div>';
} else {
var totalBanned = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.totalBanned || 0); }, 0) : 0;
var newLastHour = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.newInLastHour || 0); }, 0) : 0;
var recurringWeekCount = recurringIPsLastWeekCount();
html += ''
+ '<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">'
+ ' <div class="bg-white rounded-lg shadow p-4">'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.cards.active_jails">Active Jails</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + (summary.jails ? summary.jails.length : 0) + '</p>'
+ ' </div>'
+ ' <div class="bg-white rounded-lg shadow p-4">'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.cards.total_banned">Total Banned IPs</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + totalBanned + '</p>'
+ ' </div>'
+ ' <div class="bg-white rounded-lg shadow p-4">'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.cards.new_last_hour">New Last Hour</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + newLastHour + '</p>'
+ ' </div>'
+ ' <div class="bg-white rounded-lg shadow p-4">'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.cards.recurring_week">Recurring IPs (7 days)</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + recurringWeekCount + '</p>'
+ ' <p class="text-xs text-gray-500 mt-1" data-i18n="dashboard.cards.recurring_hint">Keep an eye on repeated offenders across all servers.</p>'
+ ' </div>'
+ '</div>';
html += ''
+ '<div class="bg-white rounded-lg shadow p-6 mb-6">'
+ ' <div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">'
+ ' <div>'
+ ' <h3 class="text-lg font-medium text-gray-900 mb-2" data-i18n="dashboard.overview">Overview active Jails and Blocks</h3>'
+ ' <p class="text-sm text-gray-500" data-i18n="dashboard.overview_hint">Use the search to filter banned IPs and click a jail to edit its configuration.</p>'
+ ' <p class="text-sm text-gray-500 mt-1" data-i18n="dashboard.overview_detail">Collapse or expand long lists to quickly focus on impacted services.</p>'
+ ' </div>'
+ ' <div>'
+ ' <label for="ipSearch" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="dashboard.search_label">Search Banned IPs</label>'
+ ' <input type="text" id="ipSearch" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="dashboard.search_placeholder" placeholder="Enter IP address to search" onkeyup="filterIPs()" pattern="[0-9.]*">'
+ ' </div>'
+ ' </div>';
if (!summary.jails || summary.jails.length === 0) {
html += '<p class="text-gray-500 mt-4" data-i18n="dashboard.no_jails">No jails found.</p>';
} else {
html += ''
+ '<div class="overflow-x-auto mt-4">'
+ ' <table class="min-w-full divide-y divide-gray-200 text-sm sm:text-base" id="jailsTable">'
+ ' <thead class="bg-gray-50">'
+ ' <tr>'
+ ' <th class="px-2 py-1 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="dashboard.table.jail">Jail</th>'
+ ' <th class="hidden sm:table-cell px-2 py-1 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="dashboard.table.total_banned">Total Banned</th>'
+ ' <th class="hidden sm:table-cell px-2 py-1 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="dashboard.table.new_last_hour">New Last Hour</th>'
+ ' <th class="px-2 py-1 sm:px-6 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="dashboard.table.banned_ips">Banned IPs</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody class="bg-white divide-y divide-gray-200">';
summary.jails.forEach(function(jail) {
var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs || []);
html += ''
+ '<tr class="jail-row hover:bg-gray-50">'
+ ' <td class="px-2 py-1 sm:px-6 sm:py-4 whitespace-normal break-words">'
+ ' <a href="#" onclick="openJailConfigModal(\'' + escapeHtml(jail.jailName) + '\')" class="text-blue-600 hover:text-blue-800">'
+ escapeHtml(jail.jailName)
+ ' </a>'
+ ' </td>'
+ ' <td class="hidden sm:table-cell px-2 py-1 sm:px-6 sm:py-4 whitespace-normal break-words">' + (jail.totalBanned || 0) + '</td>'
+ ' <td class="hidden sm:table-cell px-2 py-1 sm:px-6 sm:py-4 whitespace-normal break-words">' + (jail.newInLastHour || 0) + '</td>'
+ ' <td class="px-2 py-1 sm:px-6 sm:py-4 whitespace-normal break-words">' + bannedHTML + '</td>'
+ '</tr>';
});
html += ' </tbody></table>';
html += '</div>';
}
html += '</div>'; // close overview card
}
html += '<div id="logOverview">' + renderLogOverviewContent() + '</div>';
container.innerHTML = html;
restoreFocusState(focusState);
const extIpEl = document.getElementById('external-ip');
if (extIpEl) {
extIpEl.addEventListener('click', function() {
const ip = extIpEl.textContent.trim();
const searchInput = document.getElementById('ipSearch');
if (searchInput) {
searchInput.value = ip;
filterIPs();
searchInput.focus();
searchInput.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
filterIPs();
initializeSearch();
if (typeof updateTranslations === 'function') {
updateTranslations();
}
}
function renderLogOverviewSection() {
var target = document.getElementById('logOverview');
if (!target) return;
var focusState = captureFocusState(target);
target.innerHTML = renderLogOverviewContent();
restoreFocusState(focusState);
if (typeof updateTranslations === 'function') {
updateTranslations();
}
}
function renderLogOverviewContent() {
var html = ''
+ '<div class="bg-white rounded-lg shadow p-6 mb-6">'
+ ' <div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between mb-4">'
+ ' <div>'
+ ' <h3 class="text-lg font-medium text-gray-900" data-i18n="logs.overview.title">Internal Log Overview</h3>'
+ ' <p class="text-sm text-gray-500" data-i18n="logs.overview.subtitle">Events stored by Fail2ban-UI across all connectors.</p>'
+ ' </div>'
+ ' <button class="text-sm text-blue-600 hover:text-blue-800" onclick="refreshData()" data-i18n="logs.overview.refresh">Refresh data</button>'
+ ' </div>';
var statsKeys = Object.keys(latestBanStats || {});
statsKeys.sort(function(a, b) {
return (latestBanStats[b] || 0) - (latestBanStats[a] || 0);
});
var totalStored = totalStoredBans();
var todayCount = totalBansToday();
var weekCount = totalBansWeek();
if (statsKeys.length === 0 && totalStored === 0) {
html += '<p class="text-gray-500" data-i18n="logs.overview.empty">No ban events recorded yet.</p>';
} else {
html += ''
+ '<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">'
+ ' <div class="border border-gray-200 rounded-lg p-4 flex flex-col gap-4">'
+ ' <div class="flex items-start justify-between gap-4">'
+ ' <div>'
+ ' <p class="text-sm text-gray-500" data-i18n="logs.overview.total_events">Total stored events</p>'
+ ' <p class="text-2xl font-semibold text-gray-800">' + totalStored + '</p>'
+ ' </div>'
+ ' <button type="button" class="inline-flex items-center px-3 py-1 text-sm rounded border border-blue-200 text-blue-600 hover:bg-blue-50" onclick="openBanInsightsModal()" data-i18n="logs.overview.open_insights">Open insights</button>'
+ ' </div>'
+ ' <div class="grid grid-cols-2 gap-4 text-sm">'
+ ' <div>'
+ ' <p class="text-gray-500" data-i18n="logs.overview.total_today">Today</p>'
+ ' <p class="text-lg font-semibold text-gray-900">' + todayCount + '</p>'
+ ' </div>'
+ ' <div>'
+ ' <p class="text-gray-500" data-i18n="logs.overview.total_week">Last 7 days</p>'
+ ' <p class="text-lg font-semibold text-gray-900">' + weekCount + '</p>'
+ ' </div>'
+ ' </div>'
+ ' </div>'
+ ' <div class="border border-gray-200 rounded-lg p-4 overflow-x-auto">'
+ ' <p class="text-sm text-gray-500 mb-2" data-i18n="logs.overview.per_server">Events per server</p>'
+ ' <table class="min-w-full text-sm">'
+ ' <thead>'
+ ' <tr class="text-left text-xs text-gray-500 uppercase tracking-wider">'
+ ' <th class="pr-4" data-i18n="logs.table.server">Server</th>'
+ ' <th data-i18n="logs.table.count">Count</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody>';
if (!statsKeys.length) {
html += '<tr><td colspan="2" class="py-2 text-sm text-gray-500" data-i18n="logs.overview.per_server_empty">No per-server data available yet.</td></tr>';
} else {
statsKeys.forEach(function(serverId) {
var count = latestBanStats[serverId] || 0;
var server = serversCache.find(function(s) { return s.id === serverId; });
html += ''
+ ' <tr>'
+ ' <td class="pr-4 py-1">' + escapeHtml(server ? server.name : serverId) + '</td>'
+ ' <td class="py-1">' + count + '</td>'
+ ' </tr>';
});
}
html += ' </tbody></table></div></div>';
}
html += '<h4 class="text-md font-semibold text-gray-800 mb-3" data-i18n="logs.overview.recent_events_title">Recent stored events</h4>';
if (!latestBanEvents.length) {
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_empty">No stored events found.</p>';
} else {
var countries = getBanEventCountries();
var filteredEvents = getFilteredBanEvents();
var recurringMap = getRecurringIPMap();
var searchQuery = (banEventsFilterText || '').trim();
html += ''
+ '<div class="flex flex-col sm:flex-row gap-3 mb-4">'
+ ' <div class="flex-1">'
+ ' <label for="recentEventsSearch" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="logs.search.label">Search events</label>'
+ ' <input type="text" id="recentEventsSearch" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="' + t('logs.search.placeholder', 'Search IP, jail or server') + '" value="' + escapeHtml(banEventsFilterText) + '" oninput="updateBanEventsSearch(this.value)">'
+ ' </div>'
+ ' <div class="w-full sm:w-48">'
+ ' <label for="recentEventsCountry" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="logs.search.country_label">Country</label>'
+ ' <select id="recentEventsCountry" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="updateBanEventsCountry(this.value)">'
+ ' <option value="all"' + (banEventsFilterCountry === 'all' ? ' selected' : '') + ' data-i18n="logs.search.country_all">All countries</option>';
countries.forEach(function(country) {
var value = (country || '').trim();
var optionValue = value ? value.toLowerCase() : '__unknown__';
var label = value || t('logs.search.country_unknown', 'Unknown');
var selected = banEventsFilterCountry.toLowerCase() === optionValue ? ' selected' : '';
html += '<option value="' + optionValue + '"' + selected + '>' + escapeHtml(label) + '</option>';
});
html += ' </select>'
+ ' </div>'
+ '</div>';
html += '<p class="text-xs text-gray-500 mb-3">' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + filteredEvents.length + ' / ' + latestBanEvents.length + '</p>';
if (!filteredEvents.length) {
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_filtered_empty">No stored events match the current filters.</p>';
} else {
html += ''
+ '<div class="overflow-x-auto">'
+ ' <table class="min-w-full divide-y divide-gray-200 text-sm">'
+ ' <thead class="bg-gray-50">'
+ ' <tr>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.time">Time</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.server">Server</th>'
+ ' <th class="hidden sm:table-cell px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.jail">Jail</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.ip">IP</th>'
+ ' <th class="hidden md:table-cell px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.country">Country</th>'
+ ' <th class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider" data-i18n="logs.table.actions">Actions</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody class="bg-white divide-y divide-gray-200">';
filteredEvents.forEach(function(event) {
var index = latestBanEvents.indexOf(event);
var hasWhois = event.whois && event.whois.trim().length > 0;
var hasLogs = event.logs && event.logs.trim().length > 0;
var serverValue = event.serverName || event.serverId || '';
var jailValue = event.jail || '';
var ipValue = event.ip || '';
var serverCell = highlightQueryMatch(serverValue, searchQuery);
var jailCell = highlightQueryMatch(jailValue, searchQuery);
var ipCell = highlightQueryMatch(ipValue, searchQuery);
if (event.ip && recurringMap[event.ip]) {
ipCell += ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">' + t('logs.badge.recurring', 'Recurring') + '</span>';
}
html += ''
+ ' <tr class="hover:bg-gray-50">'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(formatDateTime(event.occurredAt || event.createdAt)) + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + serverCell + '</td>'
+ ' <td class="hidden sm:table-cell px-2 py-2 whitespace-nowrap">' + jailCell + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + ipCell + '</td>'
+ ' <td class="hidden md:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.country || '—') + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">'
+ ' <div class="flex gap-2">'
+ (hasWhois ? ' <button onclick="openWhoisModal(' + index + ')" class="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700" data-i18n="logs.actions.whois">Whois</button>' : ' <button disabled class="px-2 py-1 text-xs bg-gray-300 text-gray-500 rounded cursor-not-allowed" data-i18n="logs.actions.whois">Whois</button>')
+ (hasLogs ? ' <button onclick="openLogsModal(' + index + ')" class="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700" data-i18n="logs.actions.logs">Logs</button>' : ' <button disabled class="px-2 py-1 text-xs bg-gray-300 text-gray-500 rounded cursor-not-allowed" data-i18n="logs.actions.logs">Logs</button>')
+ ' </div>'
+ ' </td>'
+ ' </tr>';
});
html += ' </tbody></table></div>';
}
}
html += '</div>';
return html;
}
//*******************************************************************
//* Server management helper functions *
//*******************************************************************
function openServerManager(serverId) {
showLoading(true);
loadServers()
.then(function() {
if (serverId) {
editServer(serverId);
} else {
resetServerForm();
}
renderServerManagerList();
openModal('serverManagerModal');
})
.finally(function() {
showLoading(false);
});
}
function renderServerManagerList() {
var list = document.getElementById('serverManagerList');
var emptyState = document.getElementById('serverManagerListEmpty');
if (!list || !emptyState) return;
if (!serversCache.length) {
list.innerHTML = '';
emptyState.classList.remove('hidden');
if (typeof updateTranslations === 'function') updateTranslations();
return;
}
emptyState.classList.add('hidden');
var html = serversCache.map(function(server) {
var statusBadge = server.enabled
? '<span class="ml-2 text-xs font-semibold text-green-600" data-i18n="servers.badge.enabled">Enabled</span>'
: '<span class="ml-2 text-xs font-semibold text-gray-500" data-i18n="servers.badge.disabled">Disabled</span>';
var defaultBadge = server.isDefault
? '<span class="ml-2 text-xs font-semibold text-blue-600" data-i18n="servers.badge.default">Default</span>'
: '';
var descriptor = [];
if (server.type) {
descriptor.push(server.type.toUpperCase());
}
if (server.host) {
var endpoint = server.host;
if (server.port) {
endpoint += ':' + server.port;
}
descriptor.push(endpoint);
} else if (server.hostname) {
descriptor.push(server.hostname);
}
var meta = descriptor.join(' • ');
var tags = (server.tags || []).length
? '<div class="mt-2 text-xs text-gray-500">' + escapeHtml(server.tags.join(', ')) + '</div>'
: '';
return ''
+ '<div class="border border-gray-200 rounded-lg p-4">'
+ ' <div class="flex items-center justify-between">'
+ ' <div>'
+ ' <p class="font-semibold text-gray-800 flex items-center">' + escapeHtml(server.name || server.id) + defaultBadge + statusBadge + '</p>'
+ ' <p class="text-sm text-gray-500">' + escapeHtml(meta || server.id) + '</p>'
+ tags
+ ' </div>'
+ ' <div class="flex flex-col gap-2">'
+ ' <button class="text-sm text-blue-600 hover:text-blue-800" onclick="editServer(\'' + escapeHtml(server.id) + '\')" data-i18n="servers.actions.edit">Edit</button>'
+ (server.isDefault ? '' : '<button class="text-sm text-blue-600 hover:text-blue-800" onclick="makeDefaultServer(\'' + escapeHtml(server.id) + '\')" data-i18n="servers.actions.set_default">Set default</button>')
+ ' <button class="text-sm text-blue-600 hover:text-blue-800" onclick="setServerEnabled(\'' + escapeHtml(server.id) + '\',' + (server.enabled ? 'false' : 'true') + ')" data-i18n="' + (server.enabled ? 'servers.actions.disable' : 'servers.actions.enable') + '">' + (server.enabled ? 'Disable' : 'Enable') + '</button>'
+ ' <button class="text-sm text-blue-600 hover:text-blue-800" onclick="testServerConnection(\'' + escapeHtml(server.id) + '\')" data-i18n="servers.actions.test">Test connection</button>'
+ ' <button class="text-sm text-red-600 hover:text-red-800" onclick="deleteServer(\'' + escapeHtml(server.id) + '\')" data-i18n="servers.actions.delete">Delete</button>'
+ ' </div>'
+ ' </div>'
+ '</div>';
}).join('');
list.innerHTML = html;
if (typeof updateTranslations === 'function') updateTranslations();
}
function resetServerForm() {
document.getElementById('serverId').value = '';
document.getElementById('serverName').value = '';
document.getElementById('serverType').value = 'local';
document.getElementById('serverHost').value = '';
document.getElementById('serverPort').value = '';
document.getElementById('serverSocket').value = '/var/run/fail2ban/fail2ban.sock';
document.getElementById('serverLogPath').value = '/var/log/fail2ban.log';
document.getElementById('serverHostname').value = '';
document.getElementById('serverSSHUser').value = '';
document.getElementById('serverSSHKey').value = '';
document.getElementById('serverAgentUrl').value = '';
document.getElementById('serverAgentSecret').value = '';
document.getElementById('serverTags').value = '';
document.getElementById('serverDefault').checked = false;
document.getElementById('serverEnabled').checked = false;
populateSSHKeySelect(sshKeysCache || [], '');
onServerTypeChange('local');
}
function editServer(serverId) {
var server = serversCache.find(function(s) { return s.id === serverId; });
if (!server) return;
document.getElementById('serverId').value = server.id || '';
document.getElementById('serverName').value = server.name || '';
document.getElementById('serverType').value = server.type || 'local';
document.getElementById('serverHost').value = server.host || '';
document.getElementById('serverPort').value = server.port || '';
document.getElementById('serverSocket').value = server.socketPath || '/var/run/fail2ban/fail2ban.sock';
document.getElementById('serverLogPath').value = server.logPath || '/var/log/fail2ban.log';
document.getElementById('serverHostname').value = server.hostname || '';
document.getElementById('serverSSHUser').value = server.sshUser || '';
document.getElementById('serverSSHKey').value = server.sshKeyPath || '';
document.getElementById('serverAgentUrl').value = server.agentUrl || '';
document.getElementById('serverAgentSecret').value = server.agentSecret || '';
document.getElementById('serverTags').value = (server.tags || []).join(',');
document.getElementById('serverDefault').checked = !!server.isDefault;
document.getElementById('serverEnabled').checked = !!server.enabled;
onServerTypeChange(server.type || 'local');
if ((server.type || 'local') === 'ssh') {
loadSSHKeys().then(function(keys) {
populateSSHKeySelect(keys, server.sshKeyPath || '');
});
}
}
function onServerTypeChange(type) {
document.querySelectorAll('[data-server-fields]').forEach(function(el) {
var values = (el.getAttribute('data-server-fields') || '').split(/\s+/);
if (values.indexOf(type) !== -1) {
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
});
var enabledToggle = document.getElementById('serverEnabled');
if (!enabledToggle) return;
var isEditing = !!document.getElementById('serverId').value;
if (isEditing) {
return;
}
if (type === 'local') {
enabledToggle.checked = false;
} else {
enabledToggle.checked = true;
}
if (type === 'ssh') {
loadSSHKeys().then(function(keys) {
if (!isEditing) {
populateSSHKeySelect(keys, '');
}
});
} else {
populateSSHKeySelect([], '');
}
}
function submitServerForm(event) {
event.preventDefault();
showLoading(true);
var payload = {
id: document.getElementById('serverId').value || undefined,
name: document.getElementById('serverName').value.trim(),
type: document.getElementById('serverType').value,
host: document.getElementById('serverHost').value.trim(),
port: document.getElementById('serverPort').value ? parseInt(document.getElementById('serverPort').value, 10) : undefined,
socketPath: document.getElementById('serverSocket').value.trim(),
logPath: document.getElementById('serverLogPath').value.trim(),
hostname: document.getElementById('serverHostname').value.trim(),
sshUser: document.getElementById('serverSSHUser').value.trim(),
sshKeyPath: document.getElementById('serverSSHKey').value.trim(),
agentUrl: document.getElementById('serverAgentUrl').value.trim(),
agentSecret: document.getElementById('serverAgentSecret').value.trim(),
tags: document.getElementById('serverTags').value
? document.getElementById('serverTags').value.split(',').map(function(tag) { return tag.trim(); }).filter(Boolean)
: [],
enabled: document.getElementById('serverEnabled').checked
};
if (!payload.socketPath) delete payload.socketPath;
if (!payload.logPath) delete payload.logPath;
if (!payload.hostname) delete payload.hostname;
if (!payload.agentUrl) delete payload.agentUrl;
if (!payload.agentSecret) delete payload.agentSecret;
if (!payload.sshUser) delete payload.sshUser;
if (!payload.sshKeyPath) delete payload.sshKeyPath;
if (document.getElementById('serverDefault').checked) {
payload.isDefault = true;
}
if (payload.type !== 'local' && payload.type !== 'ssh') {
delete payload.socketPath;
}
if (payload.type !== 'local') {
delete payload.logPath;
}
if (payload.type !== 'ssh') {
delete payload.sshUser;
delete payload.sshKeyPath;
}
if (payload.type !== 'agent') {
delete payload.agentUrl;
delete payload.agentSecret;
}
fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast('Error saving server: ' + (data.error || 'Unknown error'), 'error');
return;
}
showToast(t('servers.form.success', 'Server saved successfully.'), 'success');
var saved = data.server || {};
currentServerId = saved.id || currentServerId;
return loadServers().then(function() {
renderServerManagerList();
renderServerSelector();
renderServerSubtitle();
if (currentServerId) {
currentServer = serversCache.find(function(s) { return s.id === currentServerId; }) || currentServer;
}
return refreshData({ silent: true });
});
})
.catch(function(err) {
showToast('Error saving server: ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function populateSSHKeySelect(keys, selected) {
var select = document.getElementById('serverSSHKeySelect');
if (!select) return;
var options = '<option value="" data-i18n="servers.form.select_key_placeholder">Manual entry</option>';
var selectedInList = false;
if (keys && keys.length) {
keys.forEach(function(key) {
var safe = escapeHtml(key);
if (selected && key === selected) {
selectedInList = true;
}
options += '<option value="' + safe + '">' + safe + '</option>';
});
} else {
options += '<option value="" disabled data-i18n="servers.form.no_keys">No SSH keys found; enter path manually</option>';
}
if (selected && !selectedInList) {
var safeSelected = escapeHtml(selected);
options += '<option value="' + safeSelected + '">' + safeSelected + '</option>';
}
select.innerHTML = options;
if (selected) {
select.value = selected;
} else {
select.value = '';
}
if (typeof updateTranslations === 'function') {
updateTranslations();
}
}
function loadSSHKeys() {
if (sshKeysCache !== null) {
populateSSHKeySelect(sshKeysCache, document.getElementById('serverSSHKey').value);
return Promise.resolve(sshKeysCache);
}
return fetch('/api/ssh/keys')
.then(function(res) { return res.json(); })
.then(function(data) {
sshKeysCache = data.keys || [];
populateSSHKeySelect(sshKeysCache, document.getElementById('serverSSHKey').value);
return sshKeysCache;
})
.catch(function(err) {
console.error('Error loading SSH keys:', err);
sshKeysCache = [];
populateSSHKeySelect(sshKeysCache, document.getElementById('serverSSHKey').value);
return sshKeysCache;
});
}
function setServerEnabled(serverId, enabled) {
var server = serversCache.find(function(s) { return s.id === serverId; });
if (!server) {
return;
}
var payload = Object.assign({}, server, { enabled: enabled });
showLoading(true);
fetch('/api/servers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast('Error saving server: ' + (data.error || 'Unknown error'), 'error');
return;
}
if (!enabled && currentServerId === serverId) {
currentServerId = null;
currentServer = null;
}
return loadServers().then(function() {
renderServerManagerList();
renderServerSelector();
renderServerSubtitle();
return refreshData({ silent: true });
});
})
.catch(function(err) {
showToast('Error saving server: ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function testServerConnection(serverId) {
if (!serverId) return;
showLoading(true);
fetch('/api/servers/' + encodeURIComponent(serverId) + '/test', {
method: 'POST'
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast(t(data.messageKey || 'servers.actions.test_failure', data.error), 'error');
return;
}
showToast(t(data.messageKey || 'servers.actions.test_success', data.message || 'Connection successful'), 'success');
})
.catch(function(err) {
showToast(t('servers.actions.test_failure', 'Connection failed') + ': ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function deleteServer(serverId) {
if (!confirm(t('servers.actions.delete_confirm', 'Delete this server entry?'))) return;
showLoading(true);
fetch('/api/servers/' + encodeURIComponent(serverId), { method: 'DELETE' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast('Error deleting server: ' + (data.error || 'Unknown error'), 'error');
return;
}
if (currentServerId === serverId) {
currentServerId = null;
currentServer = null;
}
return loadServers().then(function() {
renderServerManagerList();
renderServerSelector();
renderServerSubtitle();
return refreshData({ silent: true });
}).then(function() {
showToast(t('servers.actions.delete_success', 'Server removed'), 'success');
});
})
.catch(function(err) {
showToast('Error deleting server: ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function makeDefaultServer(serverId) {
showLoading(true);
fetch('/api/servers/' + encodeURIComponent(serverId) + '/default', { method: 'POST' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast('Error setting default server: ' + (data.error || 'Unknown error'), 'error');
return;
}
currentServerId = data.server ? data.server.id : serverId;
return loadServers().then(function() {
renderServerManagerList();
renderServerSelector();
renderServerSubtitle();
return refreshData({ silent: true });
}).then(function() {
showToast(t('servers.actions.set_default_success', 'Server set as default'), 'success');
});
})
.catch(function(err) {
showToast('Error setting default server: ' + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function highlightQueryMatch(value, query) {
var text = value || '';
if (!query) {
return escapeHtml(text);
}
var escapedPattern = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (!escapedPattern) {
return escapeHtml(text);
}
var regex = new RegExp(escapedPattern, "gi");
var highlighted = text.replace(regex, function(match) {
return "%%MARK_START%%" + match + "%%MARK_END%%";
});
return escapeHtml(highlighted)
.replace(/%%MARK_START%%/g, "<mark>")
.replace(/%%MARK_END%%/g, "</mark>");
}
// Render banned IPs with "Unban" button
function slugifyId(value, prefix) {
var input = (value || '').toString();
var base = input.toLowerCase().replace(/[^a-z0-9]+/g, '-');
var hash = 0;
for (var i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash) + input.charCodeAt(i);
hash |= 0;
}
hash = Math.abs(hash);
base = base.replace(/^-+|-+$/g, '');
if (!base) {
base = 'item';
}
return (prefix || 'id') + '-' + base + '-' + hash;
}
function renderBannedIPs(jailName, ips) {
if (!ips || ips.length === 0) {
return '<em class="text-gray-500" data-i18n="dashboard.no_banned_ips">No banned IPs</em>';
}
var listId = slugifyId(jailName || 'jail', 'banned-list');
var hiddenId = listId + '-hidden';
var toggleId = listId + '-toggle';
var maxVisible = 5;
var visible = ips.slice(0, maxVisible);
var hidden = ips.slice(maxVisible);
var content = '<div class="space-y-2">';
function bannedIpRow(ip) {
var safeIp = escapeHtml(ip);
var encodedIp = encodeURIComponent(ip);
return ''
+ '<div class="flex items-center justify-between banned-ip-item" data-ip="' + safeIp + '">'
+ ' <span class="text-sm" data-ip-value="' + encodedIp + '">' + safeIp + '</span>'
+ ' <button class="bg-yellow-500 text-white px-3 py-1 rounded text-sm hover:bg-yellow-600 transition-colors"'
+ ' onclick="unbanIP(\'' + jailName + '\', \'' + ip + '\')">'
+ ' <span data-i18n="dashboard.unban">Unban</span>'
+ ' </button>'
+ '</div>';
}
visible.forEach(function(ip) {
content += bannedIpRow(ip);
});
if (hidden.length) {
content += '<div class="space-y-2 mt-2 hidden banned-ip-hidden" id="' + hiddenId + '" data-initially-hidden="true">';
hidden.forEach(function(ip) {
content += bannedIpRow(ip);
});
content += '</div>';
var moreLabel = t('dashboard.banned.show_more', 'Show more') + ' +' + hidden.length;
var lessLabel = t('dashboard.banned.show_less', 'Hide extra');
content += ''
+ '<button type="button" class="text-xs font-semibold text-blue-600 hover:text-blue-800 banned-ip-toggle"'
+ ' id="' + toggleId + '"'
+ ' data-target="' + hiddenId + '"'
+ ' data-more-label="' + escapeHtml(moreLabel) + '"'
+ ' data-less-label="' + escapeHtml(lessLabel) + '"'
+ ' data-expanded="false"'
+ ' onclick="toggleBannedList(\'' + hiddenId + '\', \'' + toggleId + '\')">'
+ escapeHtml(moreLabel)
+ '</button>';
}
content += '</div>';
return content;
}
// Filter IPs on dashboard table
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 === "") {
// When the search query is empty, show all IP items and reset their inner HTML.
item.style.display = "";
span.textContent = originalIP;
rowHasMatch = true;
} else if (originalIP.indexOf(query) !== -1) {
// If the IP contains the query, show the item and highlight the matching text.
item.style.display = "";
span.innerHTML = highlightQueryMatch(originalIP, query);
rowHasMatch = true;
} else {
item.style.display = "none";
}
});
// hide the whole row if nothing matched
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");
}
}
//*******************************************************************
//* Functions to manage IP-bans : *
//*******************************************************************
function unbanIP(jail, ip) {
if (!confirm("Unban IP " + ip + " from jail " + jail + "?")) {
return;
}
showLoading(true);
var url = '/api/jails/' + encodeURIComponent(jail) + '/unban/' + encodeURIComponent(ip);
fetch(withServerParam(url), {
method: 'POST',
headers: serverHeaders()
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast("Error unbanning IP: " + data.error, 'error');
} else {
showToast(data.message || "IP unbanned successfully", 'success');
}
return refreshData({ silent: true });
})
.catch(function(err) {
showToast("Error: " + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
//*******************************************************************
//* Filter-mod and config-mod actions : *
//*******************************************************************
// Prevent browser extensions (like iCloud Passwords) from interfering
function preventExtensionInterference(element) {
if (!element) return;
try {
// Ensure control property exists to prevent "Cannot read properties of undefined" errors
if (!element.control) {
Object.defineProperty(element, 'control', {
value: {
type: element.type || 'textarea',
name: element.name || 'filter-config-editor',
form: null,
autocomplete: 'off'
},
writable: false,
enumerable: false,
configurable: true
});
}
// Prevent extensions from adding their own properties
Object.seal(element.control);
} catch (e) {
// Silently ignore errors
}
}
function openJailConfigModal(jailName) {
currentJailForConfig = jailName;
var textArea = document.getElementById('jailConfigTextarea');
textArea.value = '';
// Prevent browser extensions from interfering
preventExtensionInterference(textArea);
document.getElementById('modalJailName').textContent = jailName;
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;
}
textArea.value = data.config;
// Prevent extension interference before opening modal
preventExtensionInterference(textArea);
openModal('jailConfigModal');
// Call again after a short delay to ensure it's set after modal is visible
setTimeout(function() {
preventExtensionInterference(textArea);
}, 100);
})
.catch(function(err) {
showToast("Error: " + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
function saveJailConfig() {
if (!currentJailForConfig) return;
showLoading(true);
var newConfig = document.getElementById('jailConfigTextarea').value;
var url = '/api/jails/' + encodeURIComponent(currentJailForConfig) + '/config';
fetch(withServerParam(url), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ config: newConfig }),
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast("Error saving config: " + data.error, 'error');
return;
}
closeModal('jailConfigModal');
showToast(t('filter_debug.save_success', 'Filter saved and reloaded'), 'success');
return refreshData({ silent: true });
})
.catch(function(err) {
showToast("Error: " + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
// Function: openWhoisModal
// Opens the whois modal with data from the event at the given index
function openWhoisModal(eventIndex) {
if (!latestBanEvents || !latestBanEvents[eventIndex]) {
showToast("Event not found", 'error');
return;
}
var event = latestBanEvents[eventIndex];
if (!event.whois || !event.whois.trim()) {
showToast("No whois data available for this event", 'info');
return;
}
document.getElementById('whoisModalIP').textContent = event.ip || 'N/A';
var contentEl = document.getElementById('whoisModalContent');
contentEl.textContent = event.whois;
openModal('whoisModal');
}
// Function: openLogsModal
// Opens the logs modal with data from the event at the given index
// Highlights the line that caused the block
function openLogsModal(eventIndex) {
if (!latestBanEvents || !latestBanEvents[eventIndex]) {
showToast("Event not found", 'error');
return;
}
var event = latestBanEvents[eventIndex];
if (!event.logs || !event.logs.trim()) {
showToast("No logs data available for this event", 'info');
return;
}
document.getElementById('logsModalIP').textContent = event.ip || 'N/A';
document.getElementById('logsModalJail').textContent = event.jail || 'N/A';
var logs = event.logs;
var ip = event.ip || '';
var logLines = logs.split('\n');
// Determine which lines are suspicious (bad requests)
var suspiciousIndices = [];
for (var i = 0; i < logLines.length; i++) {
if (isSuspiciousLogLine(logLines[i], ip)) {
suspiciousIndices.push(i);
}
}
var contentEl = document.getElementById('logsModalContent');
if (suspiciousIndices.length) {
var highlightMap = {};
suspiciousIndices.forEach(function(idx) { highlightMap[idx] = true; });
var html = '';
for (var j = 0; j < logLines.length; j++) {
var safeLine = escapeHtml(logLines[j] || '');
if (highlightMap[j]) {
html += '<span style="display: block; background-color: #d97706; color: #fef3c7; padding: 0.25rem 0.5rem; margin: 0.125rem 0; border-radius: 0.25rem;">' + safeLine + '</span>';
} else {
html += safeLine + '\n';
}
}
contentEl.innerHTML = html;
} else {
// No suspicious lines detected; show raw logs without highlighting
contentEl.textContent = logs;
}
openModal('logsModal');
}
function isSuspiciousLogLine(line, ip) {
if (!line) {
return false;
}
var containsIP = ip && line.indexOf(ip) !== -1;
var lowered = line.toLowerCase();
// Detect HTTP status codes (>= 300 considered problematic)
var statusMatch = line.match(/"[^"]*"\s+(\d{3})\b/);
if (!statusMatch) {
statusMatch = line.match(/\s(\d{3})\s+(?:\d+|-)/);
}
var statusCode = statusMatch ? parseInt(statusMatch[1], 10) : NaN;
var hasBadStatus = !isNaN(statusCode) && statusCode >= 300;
// Detect common attack indicators in URLs/payloads
var indicators = [
'../',
'%2e%2e',
'%252e%252e',
'%24%7b',
'${',
'/etc/passwd',
'select%20',
'union%20',
'cmd=',
'wget',
'curl ',
'nslookup',
'/xmlrpc.php',
'/wp-admin',
'/cgi-bin',
'content-length: 0'
];
var hasIndicator = indicators.some(function(ind) {
return lowered.indexOf(ind) !== -1;
});
if (containsIP) {
return hasBadStatus || hasIndicator;
}
return (hasBadStatus || hasIndicator) && !ip;
}
function openBanInsightsModal() {
var countriesContainer = document.getElementById('countryStatsContainer');
var recurringContainer = document.getElementById('recurringIPsContainer');
var countries = (latestBanInsights && latestBanInsights.countries) || [];
if (!countries.length) {
countriesContainer.innerHTML = '<p class="text-sm text-gray-500" data-i18n="logs.modal.insights_countries_empty">No bans recorded for this period.</p>';
} else {
var countryHTML = countries.map(function(stat) {
var label = stat.country || t('logs.overview.country_unknown', 'Unknown');
return ''
+ '<div class="flex items-center justify-between py-2">'
+ ' <span class="font-medium">' + escapeHtml(label) + '</span>'
+ ' <span class="text-sm text-gray-600">' + (stat.count || 0) + '</span>'
+ '</div>';
}).join('');
countriesContainer.innerHTML = countryHTML;
}
var recurring = (latestBanInsights && latestBanInsights.recurring) || [];
if (!recurring.length) {
recurringContainer.innerHTML = '<p class="text-sm text-gray-500" data-i18n="logs.modal.insights_recurring_empty">No recurring IPs detected.</p>';
} else {
var recurringHTML = recurring.map(function(stat) {
var countryLabel = stat.country || t('logs.overview.country_unknown', 'Unknown');
var lastSeenLabel = stat.lastSeen ? formatDateTime(stat.lastSeen) : '—';
return ''
+ '<div class="py-2">'
+ ' <div class="flex items-center justify-between">'
+ ' <div>'
+ ' <p class="font-mono text-sm text-gray-900">' + escapeHtml(stat.ip || '—') + '</p>'
+ ' <p class="text-xs text-gray-500">' + escapeHtml(countryLabel) + '</p>'
+ ' </div>'
+ ' <div class="text-right">'
+ ' <p class="text-sm font-semibold">' + (stat.count || 0) + '×</p>'
+ ' <p class="text-xs text-gray-500">' + t('logs.overview.last_seen', 'Last seen') + ': ' + escapeHtml(lastSeenLabel) + '</p>'
+ ' </div>'
+ ' </div>'
+ '</div>';
}).join('');
recurringContainer.innerHTML = recurringHTML;
}
if (typeof updateTranslations === 'function') {
updateTranslations();
}
openModal('banInsightsModal');
}
// Function: openManageJailsModal
// Fetches the full-list of all jails (from /jails/manage) and builds a list with toggle switches.
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?.length) {
showToast("No jails found for this server.", 'info');
return;
}
const html = data.jails.map(jail => {
const isEnabled = jail.enabled ? 'checked' : '';
const escapedJailName = escapeHtml(jail.jailName);
// Escape single quotes for JavaScript string
const jsEscapedJailName = jail.jailName.replace(/'/g, "\\'");
return `
<div class="flex items-center justify-between gap-3 p-3 bg-gray-50">
<span class="text-sm font-medium flex-1">${escapedJailName}</span>
<div class="flex items-center gap-3">
<button
type="button"
onclick="openJailConfigModal('${jsEscapedJailName}')"
class="text-xs px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors whitespace-nowrap"
data-i18n="modal.filter_config_edit"
title="${t('modal.filter_config_edit', 'Edit Filter')}"
>
${t('modal.filter_config_edit', 'Edit Filter')}
</button>
<!-- slider -->
<label class="inline-flex relative items-center cursor-pointer">
<input
type="checkbox"
id="toggle-${jail.jailName.replace(/[^a-zA-Z0-9]/g, '_')}"
class="sr-only peer"
${isEnabled}
/>
<!-- track -->
<div
class="w-11 h-6 bg-gray-200 rounded-full
peer-focus:ring-4 peer-focus:ring-blue-300
peer-checked:bg-blue-600 transition-colors"
></div>
<!-- thumb -->
<span
class="absolute left-1 top-1 bg-white w-4 h-4 rounded-full
transition-transform peer-checked:translate-x-5"
></span>
</label>
</div>
</div>
`;
}).join('');
document.getElementById('jailsList').innerHTML = html;
openModal('manageJailsModal');
})
.catch(err => showToast("Error fetching jails: " + err, 'error'))
.finally(() => showLoading(false));
}
// Function: saveManageJails
// Collects the toggled states from the Manage Jails modal and sends updates to the API.
function saveManageJails() {
showLoading(true);
var updatedJails = {};
document.querySelectorAll('#jailsList > div').forEach(function(item) {
var jailName = item.querySelector('span').textContent;
var isEnabled = item.querySelector('input[type="checkbox"]').checked;
updatedJails[jailName] = isEnabled;
});
// Send updated states 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) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast("Error saving jail settings: " + data.error, 'error');
return;
}
showToast(t('jails.manage.save_success', 'Jail settings saved. Please restart Fail2ban.'), 'info');
return loadServers().then(function() {
updateRestartBanner();
return refreshData({ silent: true });
});
})
.catch(function(err) {
showToast("Error: " + err, 'error');
})
.finally(function() {
showLoading(false);
closeModal('manageJailsModal');
});
}
//*******************************************************************
//* Load current settings when opening settings page : *
//*******************************************************************
function loadSettings() {
showLoading(true);
fetch('/api/settings')
.then(res => res.json())
.then(data => {
document.getElementById('languageSelect').value = data.language || 'en';
document.getElementById('uiPort').value = data.port || 8080,
document.getElementById('debugMode').checked = data.debug || false;
document.getElementById('callbackURL').value = data.callbackUrl || '';
document.getElementById('destEmail').value = data.destemail || '';
const select = document.getElementById('alertCountries');
for (let i = 0; i < select.options.length; i++) {
select.options[i].selected = false;
}
if (!data.alertCountries || data.alertCountries.length === 0) {
select.options[0].selected = true;
} else {
for (let i = 0; i < select.options.length; i++) {
let val = select.options[i].value;
if (data.alertCountries.includes(val)) {
select.options[i].selected = true;
}
}
}
$('#alertCountries').trigger('change');
if (data.smtp) {
document.getElementById('smtpHost').value = data.smtp.host || '';
document.getElementById('smtpPort').value = data.smtp.port || 587;
document.getElementById('smtpUsername').value = data.smtp.username || '';
document.getElementById('smtpPassword').value = data.smtp.password || '';
document.getElementById('smtpFrom').value = data.smtp.from || '';
document.getElementById('smtpUseTLS').checked = data.smtp.useTLS || false;
}
document.getElementById('bantimeIncrement').checked = data.bantimeIncrement || false;
document.getElementById('banTime').value = data.bantime || '';
document.getElementById('findTime').value = data.findtime || '';
document.getElementById('maxRetry').value = data.maxretry || '';
document.getElementById('ignoreIP').value = data.ignoreip || '';
})
.catch(err => {
showToast('Error loading settings: ' + err, 'error');
})
.finally(() => showLoading(false));
}
//*******************************************************************
//* Save settings when hitting the save button : *
//*******************************************************************
function saveSettings(event) {
event.preventDefault();
showLoading(true);
const smtpSettings = {
host: document.getElementById('smtpHost').value.trim(),
port: parseInt(document.getElementById('smtpPort').value, 10) || 587,
username: document.getElementById('smtpUsername').value.trim(),
password: document.getElementById('smtpPassword').value.trim(),
from: document.getElementById('smtpFrom').value.trim(),
useTLS: document.getElementById('smtpUseTLS').checked,
};
const selectedCountries = Array.from(document.getElementById('alertCountries').selectedOptions).map(opt => opt.value);
const settingsData = {
language: document.getElementById('languageSelect').value,
port: parseInt(document.getElementById('uiPort').value, 10) || 8080,
debug: document.getElementById('debugMode').checked,
destemail: document.getElementById('destEmail').value.trim(),
callbackUrl: document.getElementById('callbackURL').value.trim(),
alertCountries: selectedCountries.length > 0 ? selectedCountries : ["ALL"],
bantimeIncrement: document.getElementById('bantimeIncrement').checked,
bantime: document.getElementById('banTime').value.trim(),
findtime: document.getElementById('findTime').value.trim(),
maxretry: parseInt(document.getElementById('maxRetry').value, 10) || 3,
ignoreip: document.getElementById('ignoreIP').value.trim(),
smtp: smtpSettings
};
fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(settingsData),
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error saving settings: ' + (data.error + (data.details || '')), 'error');
} else {
var selectedLang = $('#languageSelect').val();
loadTranslations(selectedLang);
console.log("Settings saved successfully. Restart needed? " + data.restartNeeded);
showToast(t('settings.save_success', 'Settings saved'), 'success');
if (data.restartNeeded) {
loadServers().then(function() {
updateRestartBanner();
});
}
}
})
.catch(err => showToast('Error saving settings: ' + err, 'error'))
.finally(() => showLoading(false));
}
//*******************************************************************
//* Load the list of filters from /api/filters : *
//*******************************************************************
function loadFilters() {
showLoading(true);
fetch(withServerParam('/api/filters'), {
headers: serverHeaders()
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error loading filters: ' + data.error, 'error');
return;
}
const select = document.getElementById('filterSelect');
const notice = document.getElementById('filterNotice');
if (notice) {
if (data.messageKey) {
notice.classList.remove('hidden');
notice.textContent = t(data.messageKey, data.message || '');
} else {
notice.classList.add('hidden');
notice.textContent = '';
}
}
select.innerHTML = '';
if (!data.filters || data.filters.length === 0) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No Filters Found';
select.appendChild(opt);
} else {
data.filters.forEach(f => {
const opt = document.createElement('option');
opt.value = f;
opt.textContent = f;
select.appendChild(opt);
});
}
})
.catch(err => {
showToast('Error loading filters: ' + err, 'error');
})
.finally(() => showLoading(false));
}
function sendTestEmail() {
showLoading(true);
fetch('/api/settings/test-email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error sending test email: ' + data.error, 'error');
} else {
showToast('Test email sent successfully!', 'success');
}
})
.catch(error => showToast('Error sending test email: ' + error, 'error'))
.finally(() => showLoading(false));
}
// Called when clicking "Test Filter" button
function testSelectedFilter() {
const filterName = document.getElementById('filterSelect').value;
const lines = document.getElementById('logLinesTextarea').value.split('\n').filter(line => line.trim() !== '');
if (!filterName) {
showToast('Please select a filter.', 'info');
return;
}
if (lines.length === 0) {
showToast('Please enter at least one log line to test.', 'info');
return;
}
// Hide results initially
const testResultsEl = document.getElementById('testResults');
testResultsEl.classList.add('hidden');
testResultsEl.innerHTML = '';
showLoading(true);
fetch(withServerParam('/api/filters/test'), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
filterName: filterName,
logLines: lines
})
})
.then(res => res.json())
.then(data => {
if (data.error) {
showToast('Error testing filter: ' + data.error, 'error');
return;
}
renderTestResults(data.output || '');
})
.catch(err => {
showToast('Error testing filter: ' + err, 'error');
})
.finally(() => showLoading(false));
}
function renderTestResults(output) {
const testResultsEl = document.getElementById('testResults');
let html = '<h5 class="text-lg font-medium text-white mb-4" data-i18n="filter_debug.test_results_title">Test Results</h5>';
if (!output || output.trim() === '') {
html += '<p class="text-gray-400" data-i18n="filter_debug.no_matches">No output received.</p>';
} else {
html += '<pre class="text-white whitespace-pre-wrap overflow-x-auto">' + escapeHtml(output) + '</pre>';
}
testResultsEl.innerHTML = html;
testResultsEl.classList.remove('hidden');
}
// When showing the filter section
function showFilterSection() {
const testResultsEl = document.getElementById('testResults');
if (!currentServerId) {
var notice = document.getElementById('filterNotice');
if (notice) {
notice.classList.remove('hidden');
notice.textContent = t('filter_debug.not_available', 'Filter debug is only available when a Fail2ban server is selected.');
}
document.getElementById('filterSelect').innerHTML = '';
document.getElementById('logLinesTextarea').value = '';
testResultsEl.innerHTML = '';
testResultsEl.classList.add('hidden');
return;
}
loadFilters();
testResultsEl.innerHTML = '';
testResultsEl.classList.add('hidden');
document.getElementById('logLinesTextarea').value = '';
}
//*******************************************************************
//* Restart fail2ban action : *
//*******************************************************************
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;
showLoading(true);
fetch(withServerParam('/api/fail2ban/restart'), {
method: 'POST',
headers: serverHeaders()
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
showToast("Failed to restart Fail2ban: " + data.error, 'error');
return;
}
return loadServers().then(function() {
updateRestartBanner();
showToast(t('restart_banner.success', 'Fail2ban restart triggered'), 'success');
return refreshData({ silent: true });
});
})
.catch(function(err) {
showToast("Failed to restart Fail2ban: " + err, 'error');
})
.finally(function() {
showLoading(false);
});
}
//*******************************************************************
//* Is executed when doc-ready : *
//*******************************************************************
$(document).ready(function() {
$('#alertCountries').select2({
placeholder: 'Select countries..',
allowClear: true,
width: '100%'
});
$('#alertCountries').on('select2:select', function(e) {
var selectedValue = e.params.data.id;
var currentValues = $('#alertCountries').val() || [];
if (selectedValue === 'ALL') {
if (currentValues.length > 1) {
$('#alertCountries').val(['ALL']).trigger('change');
}
} else {
if (currentValues.indexOf('ALL') !== -1) {
var newValues = currentValues.filter(function(value) {
return value !== 'ALL';
});
$('#alertCountries').val(newValues).trigger('change');
}
}
});
var sshKeySelect = document.getElementById('serverSSHKeySelect');
if (sshKeySelect) {
sshKeySelect.addEventListener('change', function(e) {
if (e.target.value) {
document.getElementById('serverSSHKey').value = e.target.value;
}
});
}
});
//*******************************************************************
//* Translation Related Functions : *
//*******************************************************************
// Loads translation JSON file for given language (e.g., en, de, etc.)
function loadTranslations(lang) {
$.getJSON('/locales/' + lang + '.json')
.done(function(data) {
translations = data;
updateTranslations();
})
.fail(function() {
console.error('Failed to load translations for language:', lang);
});
}
// Updates all elements with data-i18n attribute with corresponding translation.
function updateTranslations() {
$('[data-i18n]').each(function() {
var key = $(this).data('i18n');
if (translations[key]) {
$(this).text(translations[key]);
}
});
// Updates placeholders.
$('[data-i18n-placeholder]').each(function() {
var key = $(this).data('i18n-placeholder');
if (translations[key]) {
$(this).attr('placeholder', translations[key]);
}
});
}
function getTranslationsSettingsOnPageload() {
return fetch('/api/settings')
.then(function(res) { return res.json(); })
.then(function(data) {
var lang = data.language || 'en';
$('#languageSelect').val(lang);
loadTranslations(lang);
})
.catch(function(err) {
console.error('Error loading initial settings:', err);
loadTranslations('en');
});
}
</script>
</body>
</html>