feat: implement IP ban management with override functionality and UI updates
This commit is contained in:
@@ -137,6 +137,41 @@ async def auth_check(request: Request):
|
||||
return JSONResponse(content={"authenticated": False}, status_code=401)
|
||||
|
||||
|
||||
# ── Protected Ban Management API ─────────────────────────────────────
|
||||
|
||||
|
||||
class BanOverrideRequest(BaseModel):
|
||||
ip: str
|
||||
action: str # "ban", "unban", or "reset"
|
||||
|
||||
|
||||
@router.post("/api/ban-override")
|
||||
async def ban_override(request: Request, body: BanOverrideRequest):
|
||||
if not verify_auth(request):
|
||||
return JSONResponse(content={"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
db = get_db()
|
||||
action_map = {"ban": True, "unban": False, "reset": None}
|
||||
override_value = action_map.get(body.action)
|
||||
if body.action not in action_map:
|
||||
return JSONResponse(
|
||||
content={"error": "Invalid action. Use: ban, unban, reset"},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if body.action == "ban":
|
||||
success = db.force_ban_ip(body.ip)
|
||||
else:
|
||||
success = db.set_ban_override(body.ip, override_value)
|
||||
|
||||
if success:
|
||||
get_app_logger().info(f"Ban override: {body.action} on IP {body.ip}")
|
||||
return JSONResponse(
|
||||
content={"success": True, "ip": body.ip, "action": body.action}
|
||||
)
|
||||
return JSONResponse(content={"error": "IP not found"}, status_code=404)
|
||||
|
||||
|
||||
@router.get("/api/all-ip-stats")
|
||||
async def all_ip_stats(request: Request):
|
||||
db = get_db()
|
||||
|
||||
@@ -434,3 +434,56 @@ async def htmx_admin(request: Request):
|
||||
"dashboard_path": _dashboard_path(request),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Ban Management HTMX Endpoints ───────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/htmx/ban/attackers")
|
||||
async def htmx_ban_attackers(
|
||||
request: Request,
|
||||
page: int = Query(1),
|
||||
page_size: int = Query(25),
|
||||
):
|
||||
if not verify_auth(request):
|
||||
return HTMLResponse(
|
||||
"<p style='color:#f85149;'>Unauthorized</p>", status_code=200
|
||||
)
|
||||
|
||||
db = get_db()
|
||||
result = db.get_attackers_paginated(page=max(1, page), page_size=page_size)
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse(
|
||||
"dashboard/partials/ban_attackers_table.html",
|
||||
{
|
||||
"request": request,
|
||||
"dashboard_path": _dashboard_path(request),
|
||||
"items": result["attackers"],
|
||||
"pagination": result["pagination"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/htmx/ban/overrides")
|
||||
async def htmx_ban_overrides(
|
||||
request: Request,
|
||||
page: int = Query(1),
|
||||
page_size: int = Query(25),
|
||||
):
|
||||
if not verify_auth(request):
|
||||
return HTMLResponse(
|
||||
"<p style='color:#f85149;'>Unauthorized</p>", status_code=200
|
||||
)
|
||||
|
||||
db = get_db()
|
||||
result = db.get_ban_overrides_paginated(page=max(1, page), page_size=page_size)
|
||||
templates = get_templates()
|
||||
return templates.TemplateResponse(
|
||||
"dashboard/partials/ban_overrides_table.html",
|
||||
{
|
||||
"request": request,
|
||||
"dashboard_path": _dashboard_path(request),
|
||||
"items": result["overrides"],
|
||||
"pagination": result["pagination"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/leaflet.min.css" />
|
||||
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/MarkerCluster.css" />
|
||||
<link rel="stylesheet" href="{{ dashboard_path }}/static/vendor/css/MarkerCluster.Default.css" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&icon_names=check_circle,error,gavel,health_and_safety,warning" />
|
||||
<link rel="stylesheet" href="{{ dashboard_path }}/static/css/dashboard.css" />
|
||||
<script src="{{ dashboard_path }}/static/vendor/js/leaflet.min.js" defer></script>
|
||||
<script src="{{ dashboard_path }}/static/vendor/js/leaflet.markercluster.js" defer></script>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<a class="tab-button" :class="{ active: tab === 'ip-insight', disabled: !insightIp }" @click.prevent="insightIp && switchToIpInsight()" href="#ip-insight">
|
||||
IP Insight<span x-show="insightIp" x-text="' (' + insightIp + ')'"></span>
|
||||
</a>
|
||||
<a class="tab-button tab-right" :class="{ active: tab === 'admin' }" x-show="authenticated" x-cloak @click.prevent="switchToAdmin()" href="#admin">Admin</a>
|
||||
<a class="tab-button tab-right" :class="{ active: tab === 'admin' }" x-show="authenticated" x-cloak @click.prevent="switchToAdmin()" href="#admin">IP Banlist</a>
|
||||
{# Lock icon (not authenticated) #}
|
||||
<a class="tab-button tab-lock-btn" :class="{ 'tab-right': !authenticated }" @click.prevent="promptAuth()" x-show="!authenticated" href="#" title="Unlock protected panels">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
@@ -197,7 +197,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ==================== ADMIN TAB (protected, loaded via HTMX with server-side auth) ==================== #}
|
||||
{# ==================== IP BANLIST TAB (protected, loaded via HTMX with server-side auth) ==================== #}
|
||||
<div x-show="tab === 'admin'" x-cloak>
|
||||
<div id="admin-htmx-container"></div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
{# Page header #}
|
||||
<div class="ip-page-header">
|
||||
<div class="ip-page-header-row">
|
||||
<h1>
|
||||
<span class="ip-address-title">{{ ip_address }}</span>
|
||||
{% if stats.category %}
|
||||
@@ -11,6 +12,21 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{# Ban/Unban actions — visible only when authenticated #}
|
||||
<span class="ip-ban-actions" style="display: none; gap: 4px;">
|
||||
{% if stats.category and stats.category | lower == 'attacker' %}
|
||||
<button class="ban-icon-btn ban-icon-unban" onclick="ipBanAction('{{ ip_address | e }}', 'unban')" title="Unban">
|
||||
<span class="material-symbols-outlined">health_and_safety</span>
|
||||
<span class="ban-icon-tooltip">Unban</span>
|
||||
</button>
|
||||
{% else %}
|
||||
<button class="ban-icon-btn ban-icon-ban" onclick="ipBanAction('{{ ip_address | e }}', 'ban')" title="Add to banlist">
|
||||
<span class="material-symbols-outlined">gavel</span>
|
||||
<span class="ban-icon-tooltip">Add to banlist</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if stats.city or stats.country %}
|
||||
<p class="ip-location-subtitle">
|
||||
{{ stats.city | default('') }}{% if stats.city and stats.country %}, {% endif %}{{ stats.country | default(stats.country_code | default('')) }}
|
||||
|
||||
@@ -1,4 +1,128 @@
|
||||
<div class="table-container" style="text-align: center; padding: 60px 20px;">
|
||||
<h2 style="color: #58a6ff;">Admin Panel</h2>
|
||||
<p style="color: #8b949e; font-size: 16px;">This is a protected panel. More features coming soon.</p>
|
||||
{# Ban management panel #}
|
||||
<div x-data="banManagement()" x-init="init()">
|
||||
|
||||
{# Force ban IP form #}
|
||||
<div class="table-container" style="margin-bottom: 20px;">
|
||||
<h2>IP Banlist</h2>
|
||||
<p style="color: #8b949e; font-size: 14px; margin-bottom: 16px;">
|
||||
Force-ban a new IP or manage existing ban overrides. Changes take effect on the next banlist export cycle (every 5 minutes).
|
||||
</p>
|
||||
<form @submit.prevent="forceBan()" style="display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap;">
|
||||
<div style="flex: 1; min-width: 200px;">
|
||||
<label style="display: block; color: #8b949e; font-size: 12px; margin-bottom: 4px;">IP Address</label>
|
||||
<input type="text"
|
||||
x-model="newBanIp"
|
||||
placeholder="e.g. 192.168.1.100"
|
||||
class="auth-modal-input"
|
||||
style="width: 100%;" />
|
||||
</div>
|
||||
<button type="submit" class="ban-form-btn" :disabled="!newBanIp || banLoading">
|
||||
<span class="material-symbols-outlined">gavel</span>
|
||||
<span x-text="banLoading ? 'Banning...' : 'Force Ban IP'"></span>
|
||||
</button>
|
||||
</form>
|
||||
<p x-show="banMessage" x-text="banMessage" :style="{ color: banSuccess ? '#3fb950' : '#f85149' }" style="margin-top: 10px; font-size: 13px;" x-cloak></p>
|
||||
</div>
|
||||
|
||||
{# Attackers list with unban option #}
|
||||
<div class="table-container" style="margin-bottom: 20px;">
|
||||
<h2>Detected Attackers</h2>
|
||||
<div class="htmx-container"
|
||||
hx-get="{{ dashboard_path }}/htmx/ban/attackers?page=1"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="htmx-indicator">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Active overrides #}
|
||||
<div class="table-container">
|
||||
<h2>Active Ban Overrides</h2>
|
||||
<div id="overrides-container"
|
||||
class="htmx-container"
|
||||
hx-get="{{ dashboard_path }}/htmx/ban/overrides?page=1"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="htmx-indicator">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('banManagement', () => ({
|
||||
newBanIp: '',
|
||||
banLoading: false,
|
||||
banMessage: '',
|
||||
banSuccess: false,
|
||||
|
||||
init() {},
|
||||
|
||||
async forceBan() {
|
||||
if (!this.newBanIp) return;
|
||||
this.banLoading = true;
|
||||
this.banMessage = '';
|
||||
try {
|
||||
const resp = await fetch(`${window.__DASHBOARD_PATH__}/api/ban-override`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ ip: this.newBanIp, action: 'ban' }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
this.banSuccess = true;
|
||||
this.banMessage = `IP ${this.newBanIp} added to banlist`;
|
||||
this.newBanIp = '';
|
||||
this.refreshOverrides();
|
||||
} else {
|
||||
this.banSuccess = false;
|
||||
this.banMessage = data.error || 'Failed to ban IP';
|
||||
}
|
||||
} catch {
|
||||
this.banSuccess = false;
|
||||
this.banMessage = 'Request failed';
|
||||
}
|
||||
this.banLoading = false;
|
||||
},
|
||||
|
||||
refreshOverrides() {
|
||||
const container = document.getElementById('overrides-container');
|
||||
if (container && typeof htmx !== 'undefined') {
|
||||
htmx.ajax('GET', `${window.__DASHBOARD_PATH__}/htmx/ban/overrides?page=1`, {
|
||||
target: '#overrides-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
async function banAction(ip, action) {
|
||||
const confirmed = await krawlModal.confirm(`Are you sure you want to ${action} IP <strong>${ip}</strong>?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.__DASHBOARD_PATH__}/api/ban-override`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ ip, action }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
krawlModal.success(`${action} successful for ${ip}`);
|
||||
const overrides = document.getElementById('overrides-container');
|
||||
if (overrides) {
|
||||
htmx.ajax('GET', `${window.__DASHBOARD_PATH__}/htmx/ban/overrides?page=1`, {
|
||||
target: '#overrides-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
krawlModal.error(result.error || `Failed to ${action} IP ${ip}`);
|
||||
}
|
||||
} catch {
|
||||
krawlModal.error('Request failed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
{# HTMX fragment: Attackers with unban action #}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} attackers</span>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="pagination-btn"
|
||||
hx-get="{{ dashboard_path }}/htmx/ban/attackers?page={{ pagination.page - 1 }}"
|
||||
hx-target="closest .htmx-container"
|
||||
hx-swap="innerHTML"
|
||||
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
|
||||
<button class="pagination-btn"
|
||||
hx-get="{{ dashboard_path }}/htmx/ban/attackers?page={{ pagination.page + 1 }}"
|
||||
hx-target="closest .htmx-container"
|
||||
hx-swap="innerHTML"
|
||||
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="ip-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>IP Address</th>
|
||||
<th>Total Requests</th>
|
||||
<th>Category</th>
|
||||
<th>Location</th>
|
||||
<th>Last Seen</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ip in items %}
|
||||
<tr>
|
||||
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
|
||||
<td>{{ ip.ip | e }}</td>
|
||||
<td>{{ ip.total_requests }}</td>
|
||||
<td><span class="category-badge category-{{ ip.category | default('unknown') }}">{{ ip.category | default('unknown') | e }}</span></td>
|
||||
<td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td>
|
||||
<td>{{ ip.last_seen | format_ts }}</td>
|
||||
<td>
|
||||
<button class="ban-icon-btn ban-icon-unban" onclick="banAction('{{ ip.ip | e }}', 'unban')" title="Unban">
|
||||
<span class="material-symbols-outlined">health_and_safety</span>
|
||||
<span class="ban-icon-tooltip">Unban</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="empty-state">No attackers found</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,57 @@
|
||||
{# HTMX fragment: Active ban overrides #}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<span class="pagination-info">Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} overrides</span>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="pagination-btn"
|
||||
hx-get="{{ dashboard_path }}/htmx/ban/overrides?page={{ pagination.page - 1 }}"
|
||||
hx-target="closest .htmx-container"
|
||||
hx-swap="innerHTML"
|
||||
{% if pagination.page <= 1 %}disabled{% endif %}>Prev</button>
|
||||
<button class="pagination-btn"
|
||||
hx-get="{{ dashboard_path }}/htmx/ban/overrides?page={{ pagination.page + 1 }}"
|
||||
hx-target="closest .htmx-container"
|
||||
hx-swap="innerHTML"
|
||||
{% if pagination.page >= pagination.total_pages %}disabled{% endif %}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="ip-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>IP Address</th>
|
||||
<th>Override</th>
|
||||
<th>Category</th>
|
||||
<th>Total Requests</th>
|
||||
<th>Location</th>
|
||||
<th>Last Seen</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ip in items %}
|
||||
<tr>
|
||||
<td class="rank">{{ loop.index + (pagination.page - 1) * pagination.page_size }}</td>
|
||||
<td>{{ ip.ip | e }}</td>
|
||||
<td>
|
||||
{% if ip.ban_override == true %}
|
||||
<span class="ban-override-badge ban-override-banned">Force Banned</span>
|
||||
{% elif ip.ban_override == false %}
|
||||
<span class="ban-override-badge ban-override-unbanned">Force Unbanned</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="category-badge category-{{ ip.category | default('unknown') }}">{{ ip.category | default('unknown') | e }}</span></td>
|
||||
<td>{{ ip.total_requests }}</td>
|
||||
<td>{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}</td>
|
||||
<td>{{ ip.last_seen | format_ts }}</td>
|
||||
<td>
|
||||
<button class="ban-icon-btn ban-icon-reset" onclick="banAction('{{ ip.ip | e }}', 'reset')" title="Reset to automatic">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></svg>
|
||||
<span class="ban-icon-tooltip">Reset</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="8" class="empty-state">No active overrides</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -454,6 +454,12 @@ tbody {
|
||||
.ip-page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.ip-page-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.ip-page-header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1301,6 +1307,164 @@ tbody {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Ban Management */
|
||||
.ban-form-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
color: #f85149;
|
||||
border: 1px solid rgba(248, 81, 73, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ban-form-btn:hover:not(:disabled) {
|
||||
background: rgba(248, 81, 73, 0.3);
|
||||
border-color: rgba(248, 81, 73, 0.5);
|
||||
}
|
||||
.ban-form-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ban-form-btn .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
}
|
||||
.ban-icon-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
}
|
||||
.ban-icon-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
.ban-icon-btn .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
}
|
||||
.ban-icon-unban {
|
||||
color: #3fb950;
|
||||
}
|
||||
.ban-icon-unban:hover {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
}
|
||||
.ban-icon-reset {
|
||||
color: #8b949e;
|
||||
}
|
||||
.ban-icon-reset:hover {
|
||||
color: #c9d1d9;
|
||||
background: rgba(139, 148, 158, 0.15);
|
||||
}
|
||||
.ban-icon-ban {
|
||||
color: #f85149;
|
||||
}
|
||||
.ban-icon-ban:hover {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
}
|
||||
.ban-icon-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 8px;
|
||||
background: #1c2128;
|
||||
color: #e6edf3;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.ban-icon-btn:hover .ban-icon-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom confirm/alert modal */
|
||||
.krawl-modal-overlay {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.krawl-modal-box {
|
||||
background-color: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
animation: authModalIn 0.2s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
.krawl-modal-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
.krawl-modal-icon .material-symbols-outlined {
|
||||
font-size: 40px;
|
||||
}
|
||||
.krawl-modal-icon.krawl-modal-icon-warn .material-symbols-outlined {
|
||||
color: #d29922;
|
||||
}
|
||||
.krawl-modal-icon.krawl-modal-icon-success .material-symbols-outlined {
|
||||
color: #3fb950;
|
||||
}
|
||||
.krawl-modal-icon.krawl-modal-icon-error .material-symbols-outlined {
|
||||
color: #f85149;
|
||||
}
|
||||
.krawl-modal-message {
|
||||
padding: 16px 24px 8px;
|
||||
text-align: center;
|
||||
color: #e6edf3;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.krawl-modal-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
.ban-override-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.ban-override-banned {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
color: #f85149;
|
||||
}
|
||||
.ban-override-unbanned {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: #3fb950;
|
||||
}
|
||||
|
||||
/* Attack Types Cell Styling */
|
||||
.attack-types-cell {
|
||||
max-width: 280px;
|
||||
|
||||
@@ -31,6 +31,10 @@ document.addEventListener('alpine:init', () => {
|
||||
if (resp.ok) this.authenticated = true;
|
||||
} catch {}
|
||||
|
||||
// Sync ban action button visibility with auth state
|
||||
this.$watch('authenticated', (val) => updateBanActionVisibility(val));
|
||||
updateBanActionVisibility(this.authenticated);
|
||||
|
||||
// Handle hash-based tab routing
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash === 'ip-stats' || hash === 'attacks') {
|
||||
@@ -261,6 +265,94 @@ window.openIpInsight = function(ip) {
|
||||
}
|
||||
};
|
||||
|
||||
// Custom modal system (replaces native confirm/alert)
|
||||
window.krawlModal = {
|
||||
_create(icon, iconClass, message, buttons) {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'krawl-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="krawl-modal-box">
|
||||
<div class="krawl-modal-icon ${iconClass}">
|
||||
<span class="material-symbols-outlined">${icon}</span>
|
||||
</div>
|
||||
<div class="krawl-modal-message">${message}</div>
|
||||
<div class="krawl-modal-actions" id="krawl-modal-actions"></div>
|
||||
</div>`;
|
||||
const actions = overlay.querySelector('#krawl-modal-actions');
|
||||
buttons.forEach(btn => {
|
||||
const el = document.createElement('button');
|
||||
el.className = `auth-modal-btn ${btn.cls}`;
|
||||
el.textContent = btn.label;
|
||||
el.onclick = () => { overlay.remove(); resolve(btn.value); };
|
||||
actions.appendChild(el);
|
||||
});
|
||||
overlay.addEventListener('click', e => {
|
||||
if (e.target === overlay) { overlay.remove(); resolve(false); }
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
});
|
||||
},
|
||||
confirm(message) {
|
||||
return this._create('warning', 'krawl-modal-icon-warn', message, [
|
||||
{ label: 'Cancel', cls: 'auth-modal-btn-cancel', value: false },
|
||||
{ label: 'Confirm', cls: 'auth-modal-btn-submit', value: true },
|
||||
]);
|
||||
},
|
||||
success(message) {
|
||||
return this._create('check_circle', 'krawl-modal-icon-success', message, [
|
||||
{ label: 'OK', cls: 'auth-modal-btn-submit', value: true },
|
||||
]);
|
||||
},
|
||||
error(message) {
|
||||
return this._create('error', 'krawl-modal-icon-error', message, [
|
||||
{ label: 'OK', cls: 'auth-modal-btn-cancel', value: true },
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
// Global ban action for IP insight page (auth-gated)
|
||||
window.ipBanAction = async function(ip, action) {
|
||||
// Check if authenticated
|
||||
const container = document.querySelector('[x-data="dashboardApp()"]');
|
||||
const data = container && (Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]));
|
||||
if (!data || !data.authenticated) {
|
||||
if (data && typeof data.promptAuth === 'function') data.promptAuth();
|
||||
return;
|
||||
}
|
||||
const confirmed = await krawlModal.confirm(`Are you sure you want to ${action} IP <strong>${ip}</strong>?`);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
const resp = await fetch(`${window.__DASHBOARD_PATH__}/api/ban-override`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ ip, action }),
|
||||
});
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
if (resp.ok) {
|
||||
krawlModal.success(result.message || `${action} successful for ${ip}`);
|
||||
} else {
|
||||
krawlModal.error(result.error || `Failed to ${action} IP ${ip}`);
|
||||
}
|
||||
} catch {
|
||||
krawlModal.error('Request failed');
|
||||
}
|
||||
};
|
||||
|
||||
// Show/hide ban action buttons based on auth state
|
||||
function updateBanActionVisibility(authenticated) {
|
||||
document.querySelectorAll('.ip-ban-actions').forEach(el => {
|
||||
el.style.display = authenticated ? 'inline-flex' : 'none';
|
||||
});
|
||||
}
|
||||
// Update visibility after HTMX swaps in new content
|
||||
document.addEventListener('htmx:afterSwap', () => {
|
||||
const container = document.querySelector('[x-data="dashboardApp()"]');
|
||||
const data = container && (Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]));
|
||||
if (data) updateBanActionVisibility(data.authenticated);
|
||||
});
|
||||
|
||||
// Utility function for formatting timestamps (used by map popups)
|
||||
function formatTimestamp(isoTimestamp) {
|
||||
if (!isoTimestamp) return 'N/A';
|
||||
|
||||
Reference in New Issue
Block a user