Files
krawl.es/src/templates/static/js/dashboard.js

385 lines
15 KiB
JavaScript

// Alpine.js Dashboard Application
document.addEventListener('alpine:init', () => {
Alpine.data('dashboardApp', () => ({
// State
tab: 'overview',
dashboardPath: window.__DASHBOARD_PATH__ || '',
// Banlist dropdown
banlistOpen: false,
// Raw request modal
rawModal: { show: false, content: '', logId: null },
// Map state
mapInitialized: false,
// Chart state
chartLoaded: false,
// IP Insight state
insightIp: null,
// Auth state (UI only — actual security enforced server-side via cookie)
authenticated: false,
authModal: { show: false, password: '', error: '', loading: false },
async init() {
// Check if already authenticated (cookie-based)
try {
const resp = await fetch(`${this.dashboardPath}/api/auth/check`, { credentials: 'same-origin' });
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') {
this.switchToAttacks();
}
// ip-insight tab is only accessible via lens buttons, not direct hash navigation
window.addEventListener('hashchange', () => {
const h = window.location.hash.slice(1);
if (h === 'ip-stats' || h === 'attacks') {
this.switchToAttacks();
} else if (h === 'banlist') {
if (this.authenticated) this.switchToBanlist();
} else if (h !== 'ip-insight') {
if (this.tab !== 'ip-insight') {
this.switchToOverview();
}
}
});
},
switchToAttacks() {
this.tab = 'attacks';
window.location.hash = '#ip-stats';
// Delay chart initialization to ensure the container is visible
this.$nextTick(() => {
setTimeout(() => {
if (!this.chartLoaded && typeof loadAttackTypesChart === 'function') {
loadAttackTypesChart();
this.chartLoaded = true;
}
}, 200);
});
},
switchToOverview() {
this.tab = 'overview';
window.location.hash = '#overview';
},
switchToBanlist() {
if (!this.authenticated) return;
this.tab = 'banlist';
window.location.hash = '#banlist';
this.$nextTick(() => {
const container = document.getElementById('banlist-htmx-container');
if (container && typeof htmx !== 'undefined') {
htmx.ajax('GET', `${this.dashboardPath}/htmx/banlist`, {
target: '#banlist-htmx-container',
swap: 'innerHTML'
});
}
});
},
async logout() {
try {
await fetch(`${this.dashboardPath}/api/auth/logout`, {
method: 'POST',
credentials: 'same-origin',
});
} catch {}
this.authenticated = false;
if (this.tab === 'banlist') this.switchToOverview();
},
promptAuth() {
this.authModal = { show: true, password: '', error: '', loading: false };
this.$nextTick(() => {
if (this.$refs.authPasswordInput) this.$refs.authPasswordInput.focus();
});
},
closeAuthModal() {
this.authModal.show = false;
this.authModal.password = '';
this.authModal.error = '';
this.authModal.loading = false;
},
async submitAuth() {
const password = this.authModal.password;
if (!password) {
this.authModal.error = 'Please enter a password';
return;
}
this.authModal.error = '';
this.authModal.loading = true;
try {
const msgBuf = new TextEncoder().encode(password);
const hashBuf = await crypto.subtle.digest('SHA-256', msgBuf);
const fingerprint = Array.from(new Uint8Array(hashBuf))
.map(b => b.toString(16).padStart(2, '0')).join('');
const resp = await fetch(`${this.dashboardPath}/api/auth`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ fingerprint }),
});
if (resp.ok) {
this.authenticated = true;
this.closeAuthModal();
this.switchToBanlist();
} else {
const data = await resp.json().catch(() => ({}));
this.authModal.error = data.error || 'Invalid password';
this.authModal.password = '';
this.authModal.loading = false;
if (data.locked && data.retry_after) {
let remaining = data.retry_after;
const interval = setInterval(() => {
remaining--;
if (remaining <= 0) {
clearInterval(interval);
this.authModal.error = '';
} else {
this.authModal.error = `Too many attempts. Try again in ${remaining}s`;
}
}, 1000);
}
}
} catch {
this.authModal.error = 'Authentication failed';
this.authModal.loading = false;
}
},
switchToIpInsight() {
// Only allow switching if an IP is selected
if (!this.insightIp) return;
this.tab = 'ip-insight';
window.location.hash = '#ip-insight';
},
openIpInsight(ip) {
// Set the IP and load the insight content
this.insightIp = ip;
this.tab = 'ip-insight';
window.location.hash = '#ip-insight';
// Load IP insight content via HTMX
this.$nextTick(() => {
const container = document.getElementById('ip-insight-htmx-container');
if (container && typeof htmx !== 'undefined') {
htmx.ajax('GET', `${this.dashboardPath}/htmx/ip-insight/${encodeURIComponent(ip)}`, {
target: '#ip-insight-htmx-container',
swap: 'innerHTML'
});
}
});
},
async viewRawRequest(logId) {
try {
const resp = await fetch(
`${this.dashboardPath}/api/raw-request/${logId}`,
{ cache: 'no-store' }
);
if (resp.status === 404) {
alert('Raw request not available');
return;
}
const data = await resp.json();
this.rawModal.content = data.raw_request || 'No content available';
this.rawModal.logId = logId;
this.rawModal.show = true;
} catch (err) {
alert('Failed to load raw request');
}
},
closeRawModal() {
this.rawModal.show = false;
this.rawModal.content = '';
this.rawModal.logId = null;
},
async copyRawRequest(event) {
if (!this.rawModal.content) return;
const btn = event.currentTarget;
const originalHTML = btn.innerHTML;
const checkIcon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="#3fb950"><path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"/></svg>';
try {
await navigator.clipboard.writeText(this.rawModal.content);
btn.innerHTML = checkIcon;
} catch {
btn.style.color = '#f85149';
}
setTimeout(() => { btn.innerHTML = originalHTML; btn.style.color = ''; }, 1500);
},
downloadRawRequest() {
if (!this.rawModal.content) return;
const blob = new Blob([this.rawModal.content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `raw-request-${this.rawModal.logId || Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
},
toggleIpDetail(event) {
const row = event.target.closest('tr');
if (!row) return;
const detailRow = row.nextElementSibling;
if (detailRow && detailRow.classList.contains('ip-stats-row')) {
detailRow.style.display =
detailRow.style.display === 'table-row' ? 'none' : 'table-row';
}
},
}));
});
// Helper to access Alpine.js component data
function getAlpineData(selector) {
const container = document.querySelector(selector);
if (!container) return null;
return Alpine.$data ? Alpine.$data(container) : (container._x_dataStack && container._x_dataStack[0]);
}
// Global function for opening IP Insight (used by map popups)
window.openIpInsight = function(ip) {
const data = getAlpineData('[x-data="dashboardApp()"]');
if (data && typeof data.openIpInsight === 'function') {
data.openIpInsight(ip);
}
};
// Escape HTML to prevent XSS when inserting into innerHTML
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// 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 data = getAlpineData('[x-data="dashboardApp()"]');
if (!data || !data.authenticated) {
if (data && typeof data.promptAuth === 'function') data.promptAuth();
return;
}
const safeIp = escapeHtml(ip);
const safeAction = escapeHtml(action);
const confirmed = await krawlModal.confirm(`Are you sure you want to ${safeAction} IP <strong>${safeIp}</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(escapeHtml(result.message || `${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 {
krawlModal.error(escapeHtml(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 data = getAlpineData('[x-data="dashboardApp()"]');
if (data) updateBanActionVisibility(data.authenticated);
});
// Utility function for formatting timestamps (used by map popups)
function formatTimestamp(isoTimestamp) {
if (!isoTimestamp) return 'N/A';
try {
const date = new Date(isoTimestamp);
return date.toLocaleString('en-US', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
});
} catch {
return isoTimestamp;
}
}