mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
2966 lines
135 KiB
HTML
2966 lines
135 KiB
HTML
<!--
|
||
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 -->
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<!-- 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);
|
||
}
|
||
|
||
/* 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 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="bg-white rounded-lg shadow p-6"></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">
|
||
© <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 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">​</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="modal.filter_config">Filter Config:</span> <span id="modalJailName"></span>
|
||
</h3>
|
||
<div class="mt-4">
|
||
<textarea id="jailConfigTextarea" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-96 font-mono text-sm"></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">​</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-lg 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">​</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">​</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-green-400 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">​</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-green-400 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">​</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 banEventsFilterText = '';
|
||
var banEventsFilterCountry = 'all';
|
||
var banEventsFilterDebounce = null;
|
||
var translations = {};
|
||
var sshKeysCache = null;
|
||
|
||
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 '&';
|
||
case '<': return '<';
|
||
case '>': return '>';
|
||
case '"': return '"';
|
||
default: return ''';
|
||
}
|
||
});
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// Close modal
|
||
function closeModal(modalId) {
|
||
document.getElementById(modalId).classList.add('hidden');
|
||
}
|
||
// Open modal
|
||
function openModal(modalId) {
|
||
document.getElementById(modalId).classList.remove('hidden');
|
||
}
|
||
// *******************************************************************
|
||
|
||
|
||
//*******************************************************************
|
||
//* 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() {
|
||
return fetch('/api/events/bans/insights')
|
||
.then(function(res) { return res.json(); })
|
||
.then(function(data) {
|
||
latestBanInsights = data || {};
|
||
latestBanInsights.totals = latestBanInsights.totals || { overall: 0, today: 0, week: 0 };
|
||
latestBanInsights.countries = latestBanInsights.countries || [];
|
||
latestBanInsights.recurring = latestBanInsights.recurring || [];
|
||
})
|
||
.catch(function(err) {
|
||
console.error('Error fetching ban insights:', err);
|
||
if (!latestBanInsights) {
|
||
latestBanInsights = { totals: { overall: 0, today: 0, week: 0 }, countries: [], recurring: [] };
|
||
}
|
||
});
|
||
}
|
||
|
||
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 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 updateBanEventsSearch(value) {
|
||
banEventsFilterText = value || '';
|
||
if (banEventsFilterDebounce) {
|
||
clearTimeout(banEventsFilterDebounce);
|
||
}
|
||
banEventsFilterDebounce = setTimeout(function() {
|
||
renderDashboard();
|
||
}, 200);
|
||
}
|
||
|
||
function updateBanEventsCountry(value) {
|
||
banEventsFilterCountry = value || 'all';
|
||
renderDashboard();
|
||
}
|
||
|
||
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 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();
|
||
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();
|
||
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 totalStored = totalStoredBans();
|
||
|
||
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.total_logged">Stored Ban Events</p>'
|
||
+ ' <p class="text-2xl font-semibold text-gray-800">' + totalStored + '</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>'
|
||
+ ' </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 += renderLogOverview();
|
||
|
||
container.innerHTML = html;
|
||
|
||
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 renderLogOverview() {
|
||
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();
|
||
|
||
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 ipCell = escapeHtml(event.ip || '');
|
||
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">' + escapeHtml(event.serverName || event.serverId || '') + '</td>'
|
||
+ ' <td class="hidden sm:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.jail || '') + '</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);
|
||
});
|
||
}
|
||
|
||
// Render banned IPs with "Unban" button
|
||
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 content = '<div class="space-y-2">';
|
||
ips.forEach(function(ip) {
|
||
content += ''
|
||
+ '<div class="flex items-center justify-between">'
|
||
+ ' <span class="text-sm">' + ip + '</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>';
|
||
});
|
||
content += '</div>';
|
||
return content;
|
||
}
|
||
|
||
// Filter IPs on dashboard table
|
||
function filterIPs() {
|
||
const input = document.getElementById("ipSearch");
|
||
const query = input.value.trim();
|
||
|
||
// Process each row in the jails table
|
||
document.querySelectorAll("#jailsTable .jail-row").forEach(row => {
|
||
const ipItems = row.querySelectorAll("div.flex");
|
||
let rowHasMatch = false;
|
||
|
||
ipItems.forEach(li => {
|
||
const span = li.querySelector("span.text-sm");
|
||
if (!span) return;
|
||
|
||
// stash original if needed
|
||
let originalIP = span.getAttribute("data-original");
|
||
if (!originalIP) {
|
||
originalIP = span.textContent.trim();
|
||
span.setAttribute("data-original", originalIP);
|
||
}
|
||
|
||
if (query === "") {
|
||
// When the search query is empty, show all IP items and reset their inner HTML.
|
||
li.style.display = "";
|
||
span.innerHTML = originalIP;
|
||
rowHasMatch = true;
|
||
} else if (originalIP.includes(query)) {
|
||
// If the IP contains the query, show the item and highlight the matching text.
|
||
li.style.display = "";
|
||
const regex = new RegExp(query, "gi");
|
||
span.innerHTML = originalIP.replace(regex, m => `<mark>${m}</mark>`);
|
||
rowHasMatch = true;
|
||
} else {
|
||
li.style.display = "none";
|
||
}
|
||
});
|
||
|
||
// hide the whole row if nothing matched
|
||
row.style.display = rowHasMatch ? "" : "none";
|
||
});
|
||
}
|
||
|
||
//*******************************************************************
|
||
//* 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 : *
|
||
//*******************************************************************
|
||
|
||
function openJailConfigModal(jailName) {
|
||
currentJailForConfig = jailName;
|
||
var textArea = document.getElementById('jailConfigTextarea');
|
||
textArea.value = '';
|
||
|
||
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;
|
||
openModal('jailConfigModal');
|
||
})
|
||
.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' : '';
|
||
return `
|
||
<div class="flex items-center justify-between p-3 bg-gray-50">
|
||
<span class="text-sm font-medium">${jail.jailName}</span>
|
||
|
||
<!-- slider -->
|
||
<label class="inline-flex relative items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
id="toggle-${jail.jailName}"
|
||
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>
|
||
`;
|
||
}).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');
|
||
|
||
if (!filterName) {
|
||
showToast('Please select a filter.', 'info');
|
||
return;
|
||
}
|
||
|
||
showLoading(true);
|
||
fetch('/api/filters/test', {
|
||
method: 'POST',
|
||
headers: { '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.matches);
|
||
})
|
||
.catch(err => {
|
||
showToast('Error testing filter: ' + err, 'error');
|
||
})
|
||
.finally(() => showLoading(false));
|
||
}
|
||
|
||
function renderTestResults(matches) {
|
||
let html = '<h5 class="text-lg font-medium text-gray-900 mb-4" data-i18n="filter_debug.test_results_title">Test Results</h5>';
|
||
if (!matches || matches.length === 0) {
|
||
html += '<p class="text-gray-500" data-i18n="filter_debug.no_matches">No matches found.</p>';
|
||
} else {
|
||
html += '<ul>';
|
||
matches.forEach(m => {
|
||
html += '<li>' + m + '</li>';
|
||
});
|
||
html += '</ul>';
|
||
}
|
||
document.getElementById('testResults').innerHTML = html;
|
||
}
|
||
|
||
// When showing the filter section
|
||
function showFilterSection() {
|
||
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 = '';
|
||
document.getElementById('testResults').innerHTML = '';
|
||
return;
|
||
}
|
||
loadFilters();
|
||
document.getElementById('testResults').innerHTML = '';
|
||
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>
|