mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
Fix loading wrong filter problem, implement creation and deletion of filters and jails, fix some css mismatches, update the handlers and routes
This commit is contained in:
@@ -1116,66 +1116,51 @@ func GetJailFilterConfigHandler(c *gin.Context) {
|
||||
config.DebugLog("Connector resolved: %s", conn.Server().Name)
|
||||
|
||||
var filterCfg string
|
||||
var filterFilePath string
|
||||
var jailCfg string
|
||||
var jailCfgLoaded bool
|
||||
var jailFilePath string
|
||||
var filterErr error
|
||||
|
||||
// First, try to load filter config using jail name
|
||||
config.DebugLog("Loading filter config for jail: %s", jail)
|
||||
filterCfg, filterErr = conn.GetFilterConfig(c.Request.Context(), jail)
|
||||
if filterErr != nil {
|
||||
config.DebugLog("Failed to load filter config with jail name, trying to find filter from jail config: %v", filterErr)
|
||||
|
||||
// Load jail config first to check for custom filter directive
|
||||
var jailErr error
|
||||
jailCfg, jailErr = conn.GetJailConfig(c.Request.Context(), jail)
|
||||
if jailErr != nil {
|
||||
config.DebugLog("Failed to load jail config: %v", jailErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load filter config: " + filterErr.Error() + ". Also failed to load jail config: " + jailErr.Error()})
|
||||
return
|
||||
}
|
||||
jailCfgLoaded = true
|
||||
config.DebugLog("Jail config loaded, length: %d", len(jailCfg))
|
||||
|
||||
// Extract filter name from jail config
|
||||
filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg)
|
||||
if filterName == "" {
|
||||
config.DebugLog("No filter directive found in jail config")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load filter config: " + filterErr.Error() + ". No filter directive found in jail config."})
|
||||
return
|
||||
}
|
||||
|
||||
config.DebugLog("Found filter directive in jail config: %s, trying to load that filter", filterName)
|
||||
// Try loading the filter specified in jail config
|
||||
filterCfg, filterErr = conn.GetFilterConfig(c.Request.Context(), filterName)
|
||||
if filterErr != nil {
|
||||
config.DebugLog("Failed to load filter config for %s: %v", filterName, filterErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": fmt.Sprintf("Failed to load filter config. Tried '%s' (jail name) and '%s' (from jail config), both failed. Last error: %v", jail, filterName, filterErr),
|
||||
})
|
||||
return
|
||||
}
|
||||
config.DebugLog("Successfully loaded filter config for %s (from jail config directive)", filterName)
|
||||
// Always load jail config first to determine which filter to load
|
||||
config.DebugLog("Loading jail config for jail: %s", jail)
|
||||
var jailErr error
|
||||
jailCfg, jailFilePath, jailErr = conn.GetJailConfig(c.Request.Context(), jail)
|
||||
if jailErr != nil {
|
||||
config.DebugLog("Failed to load jail config: %v", jailErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + jailErr.Error()})
|
||||
return
|
||||
}
|
||||
config.DebugLog("Filter config loaded, length: %d", len(filterCfg))
|
||||
config.DebugLog("Jail config loaded, length: %d, file: %s", len(jailCfg), jailFilePath)
|
||||
|
||||
// Load jail config if not already loaded
|
||||
if !jailCfgLoaded {
|
||||
config.DebugLog("Loading jail config for jail: %s", jail)
|
||||
var jailErr error
|
||||
jailCfg, jailErr = conn.GetJailConfig(c.Request.Context(), jail)
|
||||
if jailErr != nil {
|
||||
config.DebugLog("Failed to load jail config: %v", jailErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + jailErr.Error()})
|
||||
return
|
||||
}
|
||||
config.DebugLog("Jail config loaded, length: %d", len(jailCfg))
|
||||
// Extract filter name from jail config, or use jail name as fallback
|
||||
filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg)
|
||||
if filterName == "" {
|
||||
// No filter directive found, use jail name as filter name (default behavior)
|
||||
filterName = jail
|
||||
config.DebugLog("No filter directive found in jail config, using jail name as filter name: %s", filterName)
|
||||
} else {
|
||||
config.DebugLog("Found filter directive in jail config: %s", filterName)
|
||||
}
|
||||
|
||||
// Load filter config using the determined filter name
|
||||
config.DebugLog("Loading filter config for filter: %s", filterName)
|
||||
filterCfg, filterFilePath, filterErr = conn.GetFilterConfig(c.Request.Context(), filterName)
|
||||
if filterErr != nil {
|
||||
config.DebugLog("Failed to load filter config for %s: %v", filterName, filterErr)
|
||||
// Don't fail completely - allow editing even if filter doesn't exist yet
|
||||
config.DebugLog("Continuing without filter config (filter may not exist yet)")
|
||||
filterCfg = ""
|
||||
filterFilePath = ""
|
||||
} else {
|
||||
config.DebugLog("Filter config loaded, length: %d, file: %s", len(filterCfg), filterFilePath)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"jail": jail,
|
||||
"filter": filterCfg,
|
||||
"jailConfig": jailCfg,
|
||||
"jail": jail,
|
||||
"filter": filterCfg,
|
||||
"filterFilePath": filterFilePath,
|
||||
"jailConfig": jailCfg,
|
||||
"jailFilePath": jailFilePath,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1219,15 +1204,47 @@ func SetJailFilterConfigHandler(c *gin.Context) {
|
||||
config.DebugLog("Jail preview (first 100 chars): %s", req.Jail[:min(100, len(req.Jail))])
|
||||
}
|
||||
|
||||
// Save filter config
|
||||
// Save filter config - use original filter name, not the one from the new jail config
|
||||
if req.Filter != "" {
|
||||
config.DebugLog("Saving filter config for jail: %s", jail)
|
||||
if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Filter); err != nil {
|
||||
// Load the original jail config to determine which filter was originally loaded
|
||||
originalJailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail)
|
||||
if err != nil {
|
||||
config.DebugLog("Failed to load original jail config to determine filter name: %v", err)
|
||||
// Fallback: extract from new jail config
|
||||
originalJailCfg = req.Jail
|
||||
}
|
||||
|
||||
// Extract the ORIGINAL filter name (the one that was loaded when the modal opened)
|
||||
originalFilterName := fail2ban.ExtractFilterFromJailConfig(originalJailCfg)
|
||||
if originalFilterName == "" {
|
||||
// No filter directive found in original config, use jail name as filter name (default behavior)
|
||||
originalFilterName = jail
|
||||
config.DebugLog("No filter directive found in original jail config, using jail name as filter name: %s", originalFilterName)
|
||||
} else {
|
||||
config.DebugLog("Found original filter directive in jail config: %s", originalFilterName)
|
||||
}
|
||||
|
||||
// Extract the NEW filter name from the updated jail config
|
||||
newFilterName := fail2ban.ExtractFilterFromJailConfig(req.Jail)
|
||||
if newFilterName == "" {
|
||||
newFilterName = jail
|
||||
}
|
||||
|
||||
// If the filter name changed, save to the ORIGINAL filter name (not the new one)
|
||||
// This prevents overwriting a different filter with the old filter's content
|
||||
if originalFilterName != newFilterName {
|
||||
config.DebugLog("Filter name changed from %s to %s, saving filter to original name: %s", originalFilterName, newFilterName, originalFilterName)
|
||||
} else {
|
||||
config.DebugLog("Filter name unchanged: %s", originalFilterName)
|
||||
}
|
||||
|
||||
config.DebugLog("Saving filter config for filter: %s", originalFilterName)
|
||||
if err := conn.SetFilterConfig(c.Request.Context(), originalFilterName, req.Filter); err != nil {
|
||||
config.DebugLog("Failed to save filter config: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save filter config: " + err.Error()})
|
||||
return
|
||||
}
|
||||
config.DebugLog("Filter config saved successfully")
|
||||
config.DebugLog("Filter config saved successfully to filter: %s", originalFilterName)
|
||||
} else {
|
||||
config.DebugLog("No filter config provided, skipping")
|
||||
}
|
||||
@@ -1307,7 +1324,7 @@ func TestLogpathHandler(c *gin.Context) {
|
||||
config.DebugLog("Using logpath from request body: %s", originalLogpath)
|
||||
} else {
|
||||
// Fall back to reading from saved jail config
|
||||
jailCfg, err := conn.GetJailConfig(c.Request.Context(), jail)
|
||||
jailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()})
|
||||
return
|
||||
@@ -1679,6 +1696,78 @@ func UpdateJailManagementHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Jail settings updated and fail2ban reloaded successfully"})
|
||||
}
|
||||
|
||||
// CreateJailHandler creates a new jail.
|
||||
func CreateJailHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("CreateJailHandler called (handlers.go)")
|
||||
|
||||
conn, err := resolveConnector(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
JailName string `json:"jailName" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate jail name
|
||||
if err := fail2ban.ValidateJailName(req.JailName); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// If no content provided, create minimal jail config
|
||||
if req.Content == "" {
|
||||
req.Content = fmt.Sprintf("[%s]\nenabled = false\n", req.JailName)
|
||||
}
|
||||
|
||||
// Create the jail
|
||||
if err := conn.CreateJail(c.Request.Context(), req.JailName, req.Content); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create jail: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' created successfully", req.JailName)})
|
||||
}
|
||||
|
||||
// DeleteJailHandler deletes a jail.
|
||||
func DeleteJailHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("DeleteJailHandler called (handlers.go)")
|
||||
|
||||
conn, err := resolveConnector(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
jailName := c.Param("jail")
|
||||
if jailName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Jail name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate jail name
|
||||
if err := fail2ban.ValidateJailName(jailName); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the jail
|
||||
if err := conn.DeleteJail(c.Request.Context(), jailName); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete jail: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' deleted successfully", jailName)})
|
||||
}
|
||||
|
||||
// GetSettingsHandler returns the entire AppSettings struct as JSON
|
||||
func GetSettingsHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
@@ -1871,6 +1960,78 @@ func TestFilterHandler(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CreateFilterHandler creates a new filter.
|
||||
func CreateFilterHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("CreateFilterHandler called (handlers.go)")
|
||||
|
||||
conn, err := resolveConnector(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
FilterName string `json:"filterName" binding:"required"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate filter name
|
||||
if err := fail2ban.ValidateFilterName(req.FilterName); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// If no content provided, create empty filter
|
||||
if req.Content == "" {
|
||||
req.Content = fmt.Sprintf("# Filter: %s\n", req.FilterName)
|
||||
}
|
||||
|
||||
// Create the filter
|
||||
if err := conn.CreateFilter(c.Request.Context(), req.FilterName, req.Content); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create filter: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' created successfully", req.FilterName)})
|
||||
}
|
||||
|
||||
// DeleteFilterHandler deletes a filter.
|
||||
func DeleteFilterHandler(c *gin.Context) {
|
||||
config.DebugLog("----------------------------")
|
||||
config.DebugLog("DeleteFilterHandler called (handlers.go)")
|
||||
|
||||
conn, err := resolveConnector(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filterName := c.Param("filter")
|
||||
if filterName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Filter name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate filter name
|
||||
if err := fail2ban.ValidateFilterName(filterName); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the filter
|
||||
if err := conn.DeleteFilter(c.Request.Context(), filterName); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete filter: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' deleted successfully", filterName)})
|
||||
}
|
||||
|
||||
// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON
|
||||
func ApplyFail2banSettings(jailLocalPath string) error {
|
||||
config.DebugLog("----------------------------")
|
||||
|
||||
@@ -41,6 +41,8 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
|
||||
// Routes for jail management
|
||||
api.GET("/jails/manage", ManageJailsHandler)
|
||||
api.POST("/jails/manage", UpdateJailManagementHandler)
|
||||
api.POST("/jails", CreateJailHandler)
|
||||
api.DELETE("/jails/:jail", DeleteJailHandler)
|
||||
|
||||
// Settings endpoints
|
||||
api.GET("/settings", GetSettingsHandler)
|
||||
@@ -60,9 +62,8 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
|
||||
// Filter debugger endpoints
|
||||
api.GET("/filters", ListFiltersHandler)
|
||||
api.POST("/filters/test", TestFilterHandler)
|
||||
|
||||
// TODO: create or generate new filters
|
||||
// api.POST("/filters/generate", GenerateFilterHandler)
|
||||
api.POST("/filters", CreateFilterHandler)
|
||||
api.DELETE("/filters/:filter", DeleteFilterHandler)
|
||||
|
||||
// Restart endpoint
|
||||
api.POST("/fail2ban/restart", RestartFail2banHandler)
|
||||
|
||||
@@ -317,3 +317,106 @@ mark {
|
||||
#advancedMikrotikFields, #advancedPfSenseFields {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Additional Tailwind color classes for buttons */
|
||||
.bg-red-500 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-red-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-red-600:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-purple-600 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(147 51 234 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-purple-700 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-purple-700:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(126 34 206 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
/* Ensure Font Awesome icons are visible */
|
||||
.fas, .far, .fab, .fal, .fad {
|
||||
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands", "Font Awesome 6 Pro";
|
||||
font-weight: 900;
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-rendering: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fas::before {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* Button icon spacing */
|
||||
button .fas, button .far, button .fab {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
button .fas:only-child, button .far:only-child, button .fab:only-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Additional utility classes that might be missing */
|
||||
/* Support for top-1/2 and -translate-y-1/2 (with escaped slash) */
|
||||
.top-1\/2 {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.-translate-y-1\/2 {
|
||||
--tw-translate-y: -50%;
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
/* Alternative class name without escape (for compatibility) */
|
||||
.top-1-2 {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.-translate-y-1-2 {
|
||||
--tw-translate-y: -50%;
|
||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Ensure buttons with icons display properly */
|
||||
button.inline-flex, .inline-flex {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button .fas, button .far, button .fab {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Ensure delete button is visible */
|
||||
button.bg-red-500, button.bg-red-600 {
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.bg-red-500:hover, button.bg-red-600:hover {
|
||||
background-color: rgb(220 38 38);
|
||||
}
|
||||
@@ -24,11 +24,13 @@ function loadFilters() {
|
||||
}
|
||||
}
|
||||
select.innerHTML = '';
|
||||
const deleteBtn = document.getElementById('deleteFilterBtn');
|
||||
if (!data.filters || data.filters.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'No Filters Found';
|
||||
select.appendChild(opt);
|
||||
if (deleteBtn) deleteBtn.disabled = true;
|
||||
} else {
|
||||
data.filters.forEach(f => {
|
||||
const opt = document.createElement('option');
|
||||
@@ -36,6 +38,14 @@ function loadFilters() {
|
||||
opt.textContent = f;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
// Add change listener if not already added
|
||||
if (!select.hasAttribute('data-listener-added')) {
|
||||
select.setAttribute('data-listener-added', 'true');
|
||||
select.addEventListener('change', function() {
|
||||
if (deleteBtn) deleteBtn.disabled = !select.value;
|
||||
});
|
||||
}
|
||||
if (deleteBtn) deleteBtn.disabled = !select.value;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -122,11 +132,115 @@ function showFilterSection() {
|
||||
document.getElementById('logLinesTextarea').value = '';
|
||||
testResultsEl.innerHTML = '';
|
||||
testResultsEl.classList.add('hidden');
|
||||
document.getElementById('deleteFilterBtn').disabled = true;
|
||||
return;
|
||||
}
|
||||
loadFilters();
|
||||
testResultsEl.innerHTML = '';
|
||||
testResultsEl.classList.add('hidden');
|
||||
document.getElementById('logLinesTextarea').value = '';
|
||||
// Add change listener to enable/disable delete button
|
||||
const filterSelect = document.getElementById('filterSelect');
|
||||
const deleteBtn = document.getElementById('deleteFilterBtn');
|
||||
filterSelect.addEventListener('change', function() {
|
||||
deleteBtn.disabled = !filterSelect.value;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateFilterModal() {
|
||||
document.getElementById('newFilterName').value = '';
|
||||
document.getElementById('newFilterContent').value = '';
|
||||
openModal('createFilterModal');
|
||||
}
|
||||
|
||||
function createFilter() {
|
||||
const filterName = document.getElementById('newFilterName').value.trim();
|
||||
const content = document.getElementById('newFilterContent').value.trim();
|
||||
|
||||
if (!filterName) {
|
||||
showToast('Filter name is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/filters'), {
|
||||
method: 'POST',
|
||||
headers: serverHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
filterName: filterName,
|
||||
content: content
|
||||
})
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
return res.json().then(function(data) {
|
||||
throw new Error(data.error || 'Server returned ' + res.status);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
showToast('Error creating filter: ' + data.error, 'error');
|
||||
return;
|
||||
}
|
||||
closeModal('createFilterModal');
|
||||
showToast(data.message || 'Filter created successfully', 'success');
|
||||
// Reload filters
|
||||
loadFilters();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error creating filter:', err);
|
||||
showToast('Error creating filter: ' + (err.message || err), 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
showLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFilter() {
|
||||
const filterName = document.getElementById('filterSelect').value;
|
||||
if (!filterName) {
|
||||
showToast('Please select a filter to delete', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to delete the filter "' + escapeHtml(filterName) + '"? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/filters/' + encodeURIComponent(filterName)), {
|
||||
method: 'DELETE',
|
||||
headers: serverHeaders()
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
return res.json().then(function(data) {
|
||||
throw new Error(data.error || 'Server returned ' + res.status);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
showToast('Error deleting filter: ' + data.error, 'error');
|
||||
return;
|
||||
}
|
||||
showToast(data.message || 'Filter deleted successfully', 'success');
|
||||
// Reload filters
|
||||
loadFilters();
|
||||
// Clear test results
|
||||
document.getElementById('testResults').innerHTML = '';
|
||||
document.getElementById('testResults').classList.add('hidden');
|
||||
document.getElementById('logLinesTextarea').value = '';
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error deleting filter:', err);
|
||||
showToast('Error deleting filter: ' + (err.message || err), 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
showLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,22 @@ function openJailConfigModal(jailName) {
|
||||
filterTextArea.value = data.filter || '';
|
||||
jailTextArea.value = data.jailConfig || '';
|
||||
|
||||
// Display file paths if available
|
||||
var filterFilePathEl = document.getElementById('filterFilePath');
|
||||
var jailFilePathEl = document.getElementById('jailFilePath');
|
||||
if (filterFilePathEl && data.filterFilePath) {
|
||||
filterFilePathEl.textContent = data.filterFilePath;
|
||||
filterFilePathEl.style.display = 'block';
|
||||
} else if (filterFilePathEl) {
|
||||
filterFilePathEl.style.display = 'none';
|
||||
}
|
||||
if (jailFilePathEl && data.jailFilePath) {
|
||||
jailFilePathEl.textContent = data.jailFilePath;
|
||||
jailFilePathEl.style.display = 'block';
|
||||
} else if (jailFilePathEl) {
|
||||
jailFilePathEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Check if logpath is set in jail config and show test button
|
||||
updateLogpathButtonVisibility();
|
||||
|
||||
@@ -267,9 +283,17 @@ function openManageJailsModal() {
|
||||
+ ' onclick="openJailConfigModal(\'' + jsEscapedJailName + '\')"'
|
||||
+ ' class="text-xs px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors whitespace-nowrap"'
|
||||
+ ' data-i18n="modal.filter_config_edit"'
|
||||
+ ' title="' + escapeHtml(t('modal.filter_config_edit', 'Edit Filter')) + '"'
|
||||
+ ' title="' + escapeHtml(t('modal.filter_config_edit', 'Edit Filter / Jail')) + '"'
|
||||
+ ' >'
|
||||
+ escapeHtml(t('modal.filter_config_edit', 'Edit Filter'))
|
||||
+ escapeHtml(t('modal.filter_config_edit', 'Edit Filter / Jail'))
|
||||
+ ' </button>'
|
||||
+ ' <button'
|
||||
+ ' type="button"'
|
||||
+ ' onclick="deleteJail(\'' + jsEscapedJailName + '\')"'
|
||||
+ ' class="text-xs px-3 py-1.5 bg-red-500 text-white rounded hover:bg-red-600 transition-colors whitespace-nowrap"'
|
||||
+ ' title="' + escapeHtml(t('modal.delete_jail', 'Delete Jail')) + '"'
|
||||
+ ' >'
|
||||
+ ' <i class="fas fa-trash"></i>'
|
||||
+ ' </button>'
|
||||
+ ' <label class="inline-flex relative items-center cursor-pointer">'
|
||||
+ ' <input'
|
||||
@@ -428,3 +452,152 @@ function saveManageJailsSingle(checkbox) {
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateJailModal() {
|
||||
document.getElementById('newJailName').value = '';
|
||||
document.getElementById('newJailContent').value = '';
|
||||
const filterSelect = document.getElementById('newJailFilter');
|
||||
if (filterSelect) {
|
||||
filterSelect.value = '';
|
||||
}
|
||||
|
||||
// Load filters into dropdown
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/filters'), {
|
||||
headers: serverHeaders()
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (filterSelect) {
|
||||
filterSelect.innerHTML = '<option value="">-- Select a filter --</option>';
|
||||
if (data.filters && data.filters.length > 0) {
|
||||
data.filters.forEach(filter => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = filter;
|
||||
opt.textContent = filter;
|
||||
filterSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
openModal('createJailModal');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error loading filters:', err);
|
||||
openModal('createJailModal');
|
||||
})
|
||||
.finally(() => showLoading(false));
|
||||
}
|
||||
|
||||
function updateJailConfigFromFilter() {
|
||||
const filterSelect = document.getElementById('newJailFilter');
|
||||
const jailNameInput = document.getElementById('newJailName');
|
||||
const contentTextarea = document.getElementById('newJailContent');
|
||||
|
||||
if (!filterSelect || !contentTextarea) return;
|
||||
|
||||
const selectedFilter = filterSelect.value;
|
||||
|
||||
if (!selectedFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-fill jail name if empty
|
||||
if (jailNameInput && !jailNameInput.value.trim()) {
|
||||
jailNameInput.value = selectedFilter;
|
||||
}
|
||||
|
||||
// Auto-populate jail config
|
||||
const jailName = (jailNameInput && jailNameInput.value.trim()) || selectedFilter;
|
||||
const config = `[${jailName}]
|
||||
enabled = false
|
||||
filter = ${selectedFilter}
|
||||
logpath = /var/log/auth.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
findtime = 600`;
|
||||
|
||||
contentTextarea.value = config;
|
||||
}
|
||||
|
||||
function createJail() {
|
||||
const jailName = document.getElementById('newJailName').value.trim();
|
||||
const content = document.getElementById('newJailContent').value.trim();
|
||||
|
||||
if (!jailName) {
|
||||
showToast('Jail name is required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/jails'), {
|
||||
method: 'POST',
|
||||
headers: serverHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({
|
||||
jailName: jailName,
|
||||
content: content
|
||||
})
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
return res.json().then(function(data) {
|
||||
throw new Error(data.error || 'Server returned ' + res.status);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
showToast('Error creating jail: ' + data.error, 'error');
|
||||
return;
|
||||
}
|
||||
closeModal('createJailModal');
|
||||
showToast(data.message || 'Jail created successfully', 'success');
|
||||
// Reload the manage jails modal
|
||||
openManageJailsModal();
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error creating jail:', err);
|
||||
showToast('Error creating jail: ' + (err.message || err), 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
showLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
function deleteJail(jailName) {
|
||||
if (!confirm('Are you sure you want to delete the jail "' + escapeHtml(jailName) + '"? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(true);
|
||||
fetch(withServerParam('/api/jails/' + encodeURIComponent(jailName)), {
|
||||
method: 'DELETE',
|
||||
headers: serverHeaders()
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) {
|
||||
return res.json().then(function(data) {
|
||||
throw new Error(data.error || 'Server returned ' + res.status);
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
showToast('Error deleting jail: ' + data.error, 'error');
|
||||
return;
|
||||
}
|
||||
showToast(data.message || 'Jail deleted successfully', 'success');
|
||||
// Reload the manage jails modal
|
||||
openManageJailsModal();
|
||||
// Refresh dashboard
|
||||
refreshData({ silent: true });
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error deleting jail:', err);
|
||||
showToast('Error deleting jail: ' + (err.message || err), 'error');
|
||||
})
|
||||
.finally(function() {
|
||||
showLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -157,10 +157,16 @@
|
||||
<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 class="mb-4 flex justify-between items-end">
|
||||
<div class="flex-1 mr-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>
|
||||
<div class="flex gap-2">
|
||||
<select id="filterSelect" class="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"></select>
|
||||
<button type="button" onclick="deleteFilter()" id="deleteFilterBtn" class="px-3 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" disabled title="Delete selected filter">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Textarea for log lines to test -->
|
||||
@@ -811,7 +817,10 @@
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- Filter Configuration -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_config_label">Filter Configuration</label>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.filter_config_label">Filter Configuration</label>
|
||||
<span id="filterFilePath" class="text-xs text-gray-500 font-mono" style="display: none;"></span>
|
||||
</div>
|
||||
<div class="relative" style="position: relative;">
|
||||
<textarea id="filterConfigTextarea"
|
||||
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-96 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
|
||||
@@ -841,7 +850,10 @@
|
||||
|
||||
<!-- Jail Configuration -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_config_label">Jail Configuration</label>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.jail_config_label">Jail Configuration</label>
|
||||
<span id="jailFilePath" class="text-xs text-gray-500 font-mono" style="display: none;"></span>
|
||||
</div>
|
||||
<div class="relative" style="position: relative;">
|
||||
<textarea id="jailConfigTextarea"
|
||||
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
|
||||
@@ -904,6 +916,16 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-4 flex justify-end gap-2">
|
||||
<button type="button" onclick="openCreateFilterModal()" class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-md hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span data-i18n="modal.create_filter">Create New Filter</span>
|
||||
</button>
|
||||
<button type="button" onclick="openCreateJailModal()" class="inline-flex items-center gap-2 px-4 py-2 bg-gray-600 text-white text-sm font-medium rounded-md hover:bg-gray-700 transition-colors">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span data-i18n="modal.create_jail">Create New Jail</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Dynamically filled list of jails with toggle switches -->
|
||||
<div id="jailsList" class="divide-y divide-gray-200"></div>
|
||||
</div>
|
||||
@@ -917,6 +939,93 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Jail Modal -->
|
||||
<div id="createJailModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||
|
||||
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all" style="max-width: 600px;">
|
||||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="modal.create_jail_title">Create New Jail</h3>
|
||||
<button type="button" onclick="closeModal('createJailModal')" class="text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600" aria-label="Close">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-4">
|
||||
<label for="newJailName" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_name">Jail Name</label>
|
||||
<input type="text" id="newJailName" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g., sshd" />
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.jail_name_hint">Only alphanumeric characters, dashes, and underscores are allowed.</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="newJailFilter" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_filter">Filter (optional)</label>
|
||||
<select id="newJailFilter" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="updateJailConfigFromFilter()">
|
||||
<option value="">-- Select a filter --</option>
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.jail_filter_hint">Selecting a filter will auto-populate the jail configuration.</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="newJailContent" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_config">Jail Configuration</label>
|
||||
<textarea id="newJailContent" 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 font-mono text-sm" placeholder="[jailname] enabled = false port = ssh filter = sshd logpath = /var/log/auth.log"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.jail_config_hint">Jail configuration will be auto-populated when you select a filter.</p>
|
||||
</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" onclick="createJail()" class="mt-3 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" data-i18n="modal.create">Create</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('createJailModal')" data-i18n="modal.cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Filter Modal -->
|
||||
<div id="createFilterModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
|
||||
|
||||
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all" style="max-width: 600px;">
|
||||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900" data-i18n="modal.create_filter_title">Create New Filter</h3>
|
||||
<button type="button" onclick="closeModal('createFilterModal')" class="text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600" aria-label="Close">
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-4">
|
||||
<label for="newFilterName" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_name">Filter Name</label>
|
||||
<input type="text" id="newFilterName" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="e.g., myfilter" />
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.filter_name_hint">Only alphanumeric characters, dashes, and underscores are allowed.</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="newFilterContent" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_config">Filter Configuration (optional)</label>
|
||||
<textarea id="newFilterContent" 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 font-mono text-sm" placeholder="# Filter: myfilter [Definition] failregex = ^.*Failed login.*$"></textarea>
|
||||
<p class="text-xs text-gray-500 mt-1" data-i18n="modal.filter_config_hint">If left empty, an empty filter file will be created.</p>
|
||||
</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" onclick="createFilter()" class="mt-3 w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" data-i18n="modal.create">Create</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('createFilterModal')" 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="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
|
||||
|
||||
Reference in New Issue
Block a user