release
This commit is contained in:
71
static/css/base.css
Normal file
71
static/css/base.css
Normal file
@@ -0,0 +1,71 @@
|
||||
/* === Ogólne === */
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* === Karty === */
|
||||
.card {
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #333333;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #212529;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* === Tabele === */
|
||||
.table {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.table thead th {
|
||||
background-color: #343a40;
|
||||
}
|
||||
|
||||
/* === Listy === */
|
||||
.list-group-item {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border: 1px solid #333333;
|
||||
}
|
||||
|
||||
/* === Formularze === */
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: #212529;
|
||||
color: #ffffff;
|
||||
border: 1px solid #343a40;
|
||||
}
|
||||
|
||||
/* === Linki === */
|
||||
a.text-decoration-none:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* === Lista banów === */
|
||||
tr.ban-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
td.ban-ip {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
#selection-counter {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
th:first-child,
|
||||
td:first-child {
|
||||
width: 42px;
|
||||
}
|
||||
|
||||
.navbar .form-control::placeholder {
|
||||
color: #adb5bd;
|
||||
opacity: 1;
|
||||
}
|
||||
146
static/js/bans.js
Normal file
146
static/js/bans.js
Normal file
@@ -0,0 +1,146 @@
|
||||
(function () {
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||
|
||||
// --- SEARCH / FILTER (client-side) ---
|
||||
const searchInput = $('#search-input');
|
||||
const rows = () => $$('#bans-tbody tr.ban-row');
|
||||
const noDataRow = () => $('#bans-tbody tr[data-empty]');
|
||||
|
||||
function applySearch() {
|
||||
const q = (searchInput?.value || '').trim().toLowerCase();
|
||||
let visible = 0;
|
||||
rows().forEach(r => {
|
||||
const text = r.textContent.toLowerCase();
|
||||
const show = !q || text.includes(q);
|
||||
r.classList.toggle('d-none', !show);
|
||||
if (show) visible++;
|
||||
});
|
||||
if (!visible && !noDataRow()) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.setAttribute('data-empty', '');
|
||||
tr.innerHTML = '<td colspan="6" class="text-center py-5 text-secondary">Brak wyników dla podanego filtra</td>';
|
||||
$('#bans-tbody').appendChild(tr);
|
||||
} else if (visible && noDataRow()) {
|
||||
noDataRow().remove();
|
||||
}
|
||||
}
|
||||
let t;
|
||||
searchInput?.addEventListener('input', () => { clearTimeout(t); t = setTimeout(applySearch, 120); });
|
||||
|
||||
// --- BULK SELECT ---
|
||||
const selectAll = $('#select-all');
|
||||
const counter = $('#selection-counter');
|
||||
const delBtn = $('#delete-selected');
|
||||
const bulkForm = $('#bulk-form');
|
||||
|
||||
function currentChecks() { return $$('.row-check'); }
|
||||
function selectedCount() { return currentChecks().filter(c => c.checked).length; }
|
||||
function updateState() {
|
||||
const total = currentChecks().length;
|
||||
const sel = selectedCount();
|
||||
if (counter) counter.textContent = sel + ' zaznaczonych';
|
||||
if (delBtn) delBtn.disabled = sel === 0;
|
||||
if (selectAll) {
|
||||
selectAll.checked = sel > 0 && sel === total;
|
||||
selectAll.indeterminate = sel > 0 && sel < total;
|
||||
}
|
||||
}
|
||||
selectAll?.addEventListener('change', () => {
|
||||
currentChecks().forEach(c => (c.checked = selectAll.checked));
|
||||
updateState();
|
||||
});
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.classList?.contains('row-check')) updateState();
|
||||
});
|
||||
// Kliknięcie w wiersz przełącza zaznaczenie (poza elementami interaktywnymi)
|
||||
document.addEventListener('click', (e) => {
|
||||
const row = e.target.closest('tr.ban-row');
|
||||
if (!row) return;
|
||||
if (e.target.closest('input,button,a,label,select,textarea')) return;
|
||||
const cb = row.querySelector('.row-check');
|
||||
if (cb) { cb.checked = !cb.checked; updateState(); }
|
||||
});
|
||||
updateState();
|
||||
|
||||
// Potwierdzenia akcji
|
||||
bulkForm?.addEventListener('submit', (e) => {
|
||||
const submitter = e.submitter;
|
||||
if (!submitter) return;
|
||||
const sel = selectedCount();
|
||||
const isDeleteAll = submitter.name === 'delete_all';
|
||||
const msg = isDeleteAll ? 'Usunąć WSZYSTKIE bany?' : `Usunąć ${sel} wybrane bany?`;
|
||||
if (!confirm(msg)) e.preventDefault();
|
||||
});
|
||||
|
||||
// --- ADD BAN: walidacja klientowa ---
|
||||
const addForm = $('#add-ban-form');
|
||||
addForm?.addEventListener('submit', (e) => {
|
||||
if (!addForm.checkValidity()) {
|
||||
e.preventDefault();
|
||||
addForm.classList.add('was-validated');
|
||||
}
|
||||
});
|
||||
|
||||
// --- DETAILS MODAL ---
|
||||
function formatBanDetails(data) {
|
||||
let html = '<div class="table-responsive"><table class="table table-bordered table-dark">';
|
||||
html += '<thead><tr><th>Klucz</th><th>Wartość</th></tr></thead><tbody>';
|
||||
for (let key in data) {
|
||||
let value = data[key];
|
||||
if (key === 'attack_details' || key === 'geo') {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
let nested = '<table class="table table-sm table-bordered table-dark mb-0">';
|
||||
for (let subKey in parsed) nested += `<tr><td>${subKey}</td><td>${parsed[subKey]}</td></tr>`;
|
||||
nested += '</table>';
|
||||
value = nested;
|
||||
} catch (_) { }
|
||||
}
|
||||
html += `<tr><td>${key}</td><td>${value}</td></tr>`;
|
||||
}
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// Otwórz modal po kliknięciu IP
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.ban-ip');
|
||||
if (!btn) return;
|
||||
const ip = btn.dataset.ip;
|
||||
const modalEl = document.getElementById('banModal');
|
||||
const bodyEl = document.getElementById('banModalBody');
|
||||
const titleEl = document.getElementById('banModalLabel');
|
||||
|
||||
if (titleEl) titleEl.textContent = 'Szczegóły bana dla ' + ip;
|
||||
if (bodyEl) {
|
||||
bodyEl.innerHTML = '<div class="placeholder-glow"><span class="placeholder col-12"></span><span class="placeholder col-10"></span></div>';
|
||||
}
|
||||
|
||||
fetch('/api/banned/' + encodeURIComponent(ip) + '?full_info=1')
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||
.then(data => { bodyEl.innerHTML = formatBanDetails(data); })
|
||||
.catch(() => { bodyEl.innerHTML = '<div class="text-danger">Nie udało się pobrać szczegółów.</div>'; })
|
||||
.finally(() => {
|
||||
const m = new bootstrap.Modal(modalEl);
|
||||
m.show();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('delete-all-btn')?.addEventListener('click', async () => {
|
||||
if (!confirm('Usunąć WSZYSTKIE bany?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/banned/all', { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
window.showToast?.({ text: 'Wszystkie bany usunięte', variant: 'success' });
|
||||
location.reload();
|
||||
} catch (e) {
|
||||
window.showToast?.({ text: 'Nie udało się usunąć wszystkich banów', variant: 'danger' });
|
||||
}
|
||||
});
|
||||
|
||||
const url = new URL(location.href);
|
||||
if (url.searchParams.get('created') === '1') {
|
||||
window.showToast?.({ text: 'Ban został dodany.', variant: 'success' });
|
||||
}
|
||||
})();
|
||||
279
static/js/charts.js
Normal file
279
static/js/charts.js
Normal file
@@ -0,0 +1,279 @@
|
||||
/* eslint-disable no-undef */
|
||||
// charts.js — UX v2 (fixed quick-top)
|
||||
(function () {
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||
|
||||
// Kolory dla dark mode
|
||||
const palette = {
|
||||
grid: 'rgba(255,255,255,0.1)',
|
||||
tick: 'rgba(255,255,255,0.7)',
|
||||
bar: 'rgba(54, 162, 235, 0.8)',
|
||||
bar2: 'rgba(255, 159, 64, 0.85)',
|
||||
line: 'rgba(255, 99, 132, 1)',
|
||||
lineFill: 'rgba(255, 99, 132, 0.2)',
|
||||
pie: [
|
||||
'rgba(255, 99, 132, 0.85)',
|
||||
'rgba(54, 162, 235, 0.85)',
|
||||
'rgba(255, 206, 86, 0.85)',
|
||||
'rgba(75, 192, 192, 0.85)',
|
||||
'rgba(153, 102, 255, 0.85)',
|
||||
'rgba(255, 159, 64, 0.85)',
|
||||
'rgba(199, 199, 199, 0.85)'
|
||||
]
|
||||
};
|
||||
|
||||
function emptyState(canvasId, emptyId, labels, dataArr) {
|
||||
const hasData = Array.isArray(labels) && labels.length > 0 && Array.isArray(dataArr) && dataArr.some(v => v > 0);
|
||||
const empty = document.getElementById(emptyId);
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!hasData) {
|
||||
canvas?.classList.add('d-none');
|
||||
empty?.classList.remove('d-none');
|
||||
return true;
|
||||
}
|
||||
canvas?.classList.remove('d-none');
|
||||
empty?.classList.add('d-none');
|
||||
return false;
|
||||
}
|
||||
|
||||
function csvFromSeries(labels, data, headerX = 'Label', headerY = 'Value') {
|
||||
const rows = [[headerX, headerY], ...labels.map((l, i) => [l, data[i] ?? 0])];
|
||||
return rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
}
|
||||
|
||||
function download(filename, content, mime) {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = filename; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function bindDownloads(id, labels, data) {
|
||||
$$('.chart-download').forEach(btn => {
|
||||
if (btn.dataset.target !== id) return;
|
||||
btn.addEventListener('click', () => {
|
||||
const type = btn.dataset.type;
|
||||
if (type === 'csv') {
|
||||
download(`${id}.csv`, csvFromSeries(labels, data), 'text/csv;charset=utf-8');
|
||||
} else if (type === 'png') {
|
||||
const c = document.getElementById(id);
|
||||
if (!c) return;
|
||||
const url = c.toDataURL('image/png');
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = `${id}.png`; a.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindFullscreen(id) {
|
||||
$$('.chart-fullscreen').forEach(btn => {
|
||||
if (btn.dataset.target !== id) return;
|
||||
btn.addEventListener('click', () => {
|
||||
const src = document.getElementById(id);
|
||||
const dst = document.getElementById('chartModalCanvas');
|
||||
const chart = Chart.getChart(src);
|
||||
if (!chart) return;
|
||||
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
|
||||
modal.show();
|
||||
setTimeout(() => {
|
||||
const old = Chart.getChart(dst);
|
||||
old && old.destroy();
|
||||
new Chart(dst.getContext('2d'), {
|
||||
type: chart.config.type,
|
||||
data: JSON.parse(JSON.stringify(chart.config.data)),
|
||||
options: Object.assign({}, chart.config.options, {
|
||||
maintainAspectRatio: false
|
||||
})
|
||||
});
|
||||
}, 150);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Bind przycisków szybkiego wyboru: nie wysyłamy duplikatu name,
|
||||
// tylko ustawiamy <select name="top_n"> i submitujemy formularz.
|
||||
function bindQuickTop() {
|
||||
const form = document.getElementById('options-form');
|
||||
const select = document.getElementById('top_n');
|
||||
if (!form || !select) return;
|
||||
|
||||
$$('.quick-top').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const val = btn.dataset.value;
|
||||
if (val && select) select.value = String(val);
|
||||
// odśwież aktywny stan w UI (opcjonalnie, dla natychmiastowego feedbacku)
|
||||
$$('.quick-top').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
form.requestSubmit();
|
||||
});
|
||||
});
|
||||
|
||||
// Zmiana w <select> nadal auto-submituje:
|
||||
select.addEventListener('change', () => form.requestSubmit());
|
||||
}
|
||||
|
||||
// Auto-inicjalizacja niezależnie od kolejności ładowania
|
||||
if (document.readyState === 'loading') {
|
||||
window.addEventListener('DOMContentLoaded', bindQuickTop);
|
||||
} else {
|
||||
bindQuickTop();
|
||||
}
|
||||
|
||||
function bindQuickPeriod() {
|
||||
const form = document.getElementById('options-form');
|
||||
const select = document.getElementById('period');
|
||||
if (!form || !select) return;
|
||||
|
||||
// Szybkie przyciski okresu (Tydzień/Miesiąc/Rok)
|
||||
$$('.quick-period').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const val = btn.dataset.value;
|
||||
if (val) select.value = String(val);
|
||||
|
||||
// natychmiastowy feedback w UI
|
||||
$$('.quick-period').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
form.requestSubmit();
|
||||
});
|
||||
});
|
||||
|
||||
// Zmiana w <select id="period"> też auto-submituje
|
||||
select.addEventListener('change', () => form.requestSubmit());
|
||||
}
|
||||
|
||||
// Auto-inicjalizacja (analogicznie do bindQuickTop)
|
||||
if (document.readyState === 'loading') {
|
||||
window.addEventListener('DOMContentLoaded', bindQuickPeriod);
|
||||
} else {
|
||||
bindQuickPeriod();
|
||||
}
|
||||
|
||||
// Główne API — rysowanie wykresów
|
||||
window.renderCharts = function renderCharts(stats) {
|
||||
// ====== Powody (bar - poziomo) ======
|
||||
{
|
||||
const labels = (stats.top_reasons || []).map(r => r.reason);
|
||||
const data = (stats.top_reasons || []).map(r => r.count);
|
||||
|
||||
if (!emptyState('reasonsChart', 'reasonsEmpty', labels, data)) {
|
||||
const ctx = document.getElementById('reasonsChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{ data, backgroundColor: palette.bar2, borderRadius: 6, borderSkipped: false }]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false }, tooltip: { intersect: false, mode: 'nearest' } },
|
||||
scales: {
|
||||
x: { grid: { color: palette.grid }, ticks: { color: palette.tick, precision: 0 } },
|
||||
y: { grid: { display: false }, ticks: { color: palette.tick } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
bindDownloads('reasonsChart', labels, data);
|
||||
bindFullscreen('reasonsChart');
|
||||
}
|
||||
|
||||
// ====== URL (bar - poziomo) ======
|
||||
{
|
||||
const labels = (stats.top_urls || []).map(u => u.url);
|
||||
const data = (stats.top_urls || []).map(u => u.count);
|
||||
|
||||
if (!emptyState('urlsChart', 'urlsEmpty', labels, data)) {
|
||||
const ctx = document.getElementById('urlsChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{ data, backgroundColor: palette.bar, borderRadius: 6, borderSkipped: false }]
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false }, tooltip: { intersect: false, mode: 'nearest' } },
|
||||
scales: {
|
||||
x: { grid: { color: palette.grid }, ticks: { color: palette.tick, precision: 0 } },
|
||||
y: { grid: { display: false }, ticks: { color: palette.tick } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
bindDownloads('urlsChart', labels, data);
|
||||
bindFullscreen('urlsChart');
|
||||
}
|
||||
|
||||
// ====== Kraje (pie) ======
|
||||
{
|
||||
const labels = (stats.top_countries || []).map(c => c.country);
|
||||
const data = (stats.top_countries || []).map(c => c.count);
|
||||
|
||||
if (!emptyState('countriesChart', 'countriesEmpty', labels, data)) {
|
||||
const ctx = document.getElementById('countriesChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: { labels, datasets: [{ data, backgroundColor: palette.pie, borderWidth: 1, borderColor: '#222' }] },
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: 'bottom', labels: { color: palette.tick } },
|
||||
tooltip: { callbacks: { label: (item) => `${item.label}: ${item.formattedValue}` } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
bindDownloads('countriesChart', labels, data);
|
||||
bindFullscreen('countriesChart');
|
||||
}
|
||||
|
||||
// ====== Bany w czasie (line) ======
|
||||
{
|
||||
const labels = stats.weeks || [];
|
||||
const data = stats.bans_per_week || [];
|
||||
|
||||
if (!emptyState('bansOverTimeChart', 'timeEmpty', labels, data)) {
|
||||
const ctx = document.getElementById('bansOverTimeChart').getContext('2d');
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
|
||||
gradient.addColorStop(0, palette.lineFill);
|
||||
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Bany / tydzień',
|
||||
data,
|
||||
borderColor: palette.line,
|
||||
backgroundColor: gradient,
|
||||
fill: true,
|
||||
cubicInterpolationMode: 'monotone',
|
||||
tension: 0.25,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: palette.grid }, ticks: { color: palette.tick } },
|
||||
y: { grid: { color: palette.grid }, ticks: { color: palette.tick, precision: 0 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
bindDownloads('bansOverTimeChart', labels, data);
|
||||
bindFullscreen('bansOverTimeChart');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
})();
|
||||
59
static/js/check.js
Normal file
59
static/js/check.js
Normal file
@@ -0,0 +1,59 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||
|
||||
function bindErrorsFilter() {
|
||||
const search = $('#errors-search');
|
||||
if (!search) return;
|
||||
const items = $$('#errorsAccordion .accordion-item');
|
||||
|
||||
const apply = () => {
|
||||
const q = search.value.toLowerCase().trim();
|
||||
items.forEach(it => {
|
||||
const text = it.textContent.toLowerCase();
|
||||
it.style.display = q && !text.includes(q) ? 'none' : '';
|
||||
});
|
||||
};
|
||||
|
||||
search.addEventListener('input', apply);
|
||||
apply();
|
||||
}
|
||||
|
||||
function bindEndpointsFilter() {
|
||||
const tbody = $('#endpoints-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const input = $('#endpoints-search');
|
||||
const empty = $('#endpoints-empty');
|
||||
|
||||
const applyFilter = () => {
|
||||
const q = (input?.value || '').toLowerCase().trim();
|
||||
let visible = 0;
|
||||
|
||||
$$('#endpoints-tbody tr').forEach(tr => {
|
||||
const txt = tr.textContent.toLowerCase();
|
||||
const show = !q || txt.includes(q);
|
||||
tr.style.display = show ? '' : 'none';
|
||||
if (show) visible++;
|
||||
});
|
||||
|
||||
empty?.classList.toggle('d-none', visible !== 0);
|
||||
};
|
||||
|
||||
input?.addEventListener('input', applyFilter);
|
||||
applyFilter();
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindErrorsFilter();
|
||||
bindEndpointsFilter();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
93
static/js/dashboard.js
Normal file
93
static/js/dashboard.js
Normal file
@@ -0,0 +1,93 @@
|
||||
(function () {
|
||||
const $ = (s, r = document) => r.querySelector(s);
|
||||
|
||||
// --- helpers ---
|
||||
function toAbsoluteUrl(u) {
|
||||
try { return new URL(u, window.location.origin).href; } catch { return u; }
|
||||
}
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch (_) { /* fallback below */ }
|
||||
|
||||
// Fallback dla nieszyfrowanego HTTP / braku uprawnień
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.top = '-1000px';
|
||||
ta.style.left = '-1000px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, ta.value.length);
|
||||
let ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (_) { ok = false; }
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function flashButton(btn, okText = 'Skopiowano!', ms = 1200) {
|
||||
if (!btn) return;
|
||||
const origText = btn.textContent;
|
||||
const hadSuccess = btn.classList.contains('btn-outline-success');
|
||||
btn.textContent = okText;
|
||||
btn.classList.add('btn-outline-success');
|
||||
btn.classList.remove('btn-outline-light', 'btn-outline-secondary');
|
||||
setTimeout(() => {
|
||||
btn.textContent = origText;
|
||||
btn.classList.toggle('btn-success', hadSuccess);
|
||||
if (btn.classList.contains('copy-url')) btn.classList.add('btn-outline-light');
|
||||
if (btn.classList.contains('copy-curl')) btn.classList.add('btn-outline-light');
|
||||
}, ms);
|
||||
}
|
||||
|
||||
// --- main: endpoint buttons ---
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btnUrl = e.target.closest('.copy-url');
|
||||
const btnCurl = e.target.closest('.copy-curl');
|
||||
|
||||
if (btnUrl) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const raw = btnUrl.dataset.url || '';
|
||||
const href = toAbsoluteUrl(raw);
|
||||
const ok = await copyToClipboard(href);
|
||||
if (ok) {
|
||||
flashButton(btnUrl);
|
||||
window.showToast?.({ text: 'Skopiowano URL.', variant: 'success' });
|
||||
} else {
|
||||
window.showToast?.({ text: 'Nie udało się skopiować URL.', variant: 'danger' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (btnCurl) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const raw = btnCurl.dataset.url || '';
|
||||
const href = toAbsoluteUrl(raw);
|
||||
const method = (btnCurl.dataset.method || 'GET').toUpperCase();
|
||||
const cmd = `curl -X ${method} "${href}" -H "Accept: application/json" -sS`;
|
||||
const ok = await copyToClipboard(cmd);
|
||||
if (ok) {
|
||||
flashButton(btnCurl);
|
||||
window.showToast?.({ text: 'Skopiowano cURL.', variant: 'success' });
|
||||
} else {
|
||||
window.showToast?.({ text: 'Nie udało się skopiować cURL.', variant: 'danger' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// --- collapse label sync ---
|
||||
const epCollapseEl = $('#ep-collapse');
|
||||
const epToggleBtn = $('#btn-toggle-endpoints');
|
||||
if (epCollapseEl && epToggleBtn && window.bootstrap) {
|
||||
epCollapseEl.addEventListener('shown.bs.collapse', () => { epToggleBtn.textContent = 'Ukryj endpointy'; });
|
||||
epCollapseEl.addEventListener('hidden.bs.collapse', () => { epToggleBtn.textContent = 'Pokaż endpointy'; });
|
||||
}
|
||||
})();
|
||||
39
static/js/delete_bans.js
Normal file
39
static/js/delete_bans.js
Normal file
@@ -0,0 +1,39 @@
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const selectAll = document.getElementById('select-all');
|
||||
const checks = () => Array.from(document.querySelectorAll('.row-check'));
|
||||
const counter = document.getElementById('selection-counter');
|
||||
const bulkBtn = document.getElementById('delete-selected');
|
||||
|
||||
function refreshState() {
|
||||
const picked = checks().filter(c => c.checked);
|
||||
counter.textContent = `${picked.length} zaznaczonych`;
|
||||
bulkBtn.disabled = picked.length === 0;
|
||||
}
|
||||
|
||||
if (selectAll) {
|
||||
selectAll.addEventListener('change', () => {
|
||||
checks().forEach(c => { c.checked = selectAll.checked; });
|
||||
refreshState();
|
||||
});
|
||||
}
|
||||
checks().forEach(c => c.addEventListener('change', refreshState));
|
||||
refreshState();
|
||||
|
||||
const delAll = document.getElementById('delete-all-btn');
|
||||
if (delAll) {
|
||||
delAll.addEventListener('click', async () => {
|
||||
if (!confirm('Na pewno usunąć WSZYSTKIE bany?')) return;
|
||||
try {
|
||||
const res = await fetch(window.DELETE_ALL_BANS_URL, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.status === 'all_bans_removed') {
|
||||
window.location.reload();
|
||||
} else {
|
||||
window.showToast?.({ text: 'Błąd podczas usuwania', variant: 'danger' });
|
||||
}
|
||||
} catch (e) {
|
||||
window.showToast?.({ text: 'Błąd sieci', variant: 'danger' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
64
static/js/ip_validate.js
Normal file
64
static/js/ip_validate.js
Normal file
@@ -0,0 +1,64 @@
|
||||
(function () {
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
|
||||
const ipInput = $('#ip');
|
||||
const formIp = $('#reset-ip-form');
|
||||
const formAll = $('#reset-all-form');
|
||||
|
||||
const btnConfirmIp = $('#btn-confirm-ip');
|
||||
const btnConfirmAll = $('#btn-confirm-all');
|
||||
const confirmIpVal = $('#confirm-ip-value');
|
||||
|
||||
(function initMyIp() {
|
||||
const card = document.querySelector('.card[data-myip]');
|
||||
const my = card?.dataset.myip?.trim();
|
||||
const btn = $('#btn-myip');
|
||||
if (my && btn) {
|
||||
btn.disabled = false;
|
||||
btn.addEventListener('click', () => {
|
||||
ipInput.value = my;
|
||||
ipInput.dispatchEvent(new Event('input'));
|
||||
window.showToast?.({ text: `Użyto adresu: ${my}`, variant: 'dark' });
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// Walidacja IPv4 na żywo
|
||||
const ipv4 = /^\d{1,3}(\.\d{1,3}){3}$/;
|
||||
ipInput?.addEventListener('input', () => {
|
||||
const ok = ipv4.test(ipInput.value.trim());
|
||||
ipInput.classList.toggle('is-valid', ok);
|
||||
ipInput.classList.toggle('is-invalid', ipInput.value.length > 0 && !ok);
|
||||
});
|
||||
|
||||
// Uzupełnij wartość w modalu przed submit
|
||||
document.getElementById('confirmResetIpModal')?.addEventListener('show.bs.modal', () => {
|
||||
const ip = ipInput.value.trim() || '—';
|
||||
confirmIpVal && (confirmIpVal.textContent = ip);
|
||||
});
|
||||
|
||||
// Blokada przycisków na czas submitu + lekki feedback
|
||||
function withSubmitting(btn, fn) {
|
||||
if (!btn) return fn();
|
||||
const original = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Przetwarzanie...';
|
||||
const done = () => { btn.disabled = false; btn.innerHTML = original; };
|
||||
const ret = fn();
|
||||
// jeśli to klasyczny submit (nawigacja), UI i tak się przeładuje
|
||||
if (ret && ret.finally) ret.finally(done); else setTimeout(done, 1200);
|
||||
}
|
||||
|
||||
// Submity (po potwierdzeniach w modalach)
|
||||
formIp?.addEventListener('submit', (e) => {
|
||||
if (!ipv4.test(ipInput.value.trim())) {
|
||||
e.preventDefault();
|
||||
ipInput.classList.add('is-invalid');
|
||||
window.showToast?.({ text: 'Nieprawidłowy adres IP.', variant: 'danger' });
|
||||
return;
|
||||
}
|
||||
withSubmitting(btnConfirmIp, () => null);
|
||||
});
|
||||
|
||||
formAll?.addEventListener('submit', () => withSubmitting(btnConfirmAll, () => null));
|
||||
})();
|
||||
286
static/js/logs.js
Normal file
286
static/js/logs.js
Normal file
@@ -0,0 +1,286 @@
|
||||
(function () {
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||
|
||||
const logContainer = $("#logContainer");
|
||||
if (!logContainer) return;
|
||||
|
||||
const displayLevelForm = $("#displayLevelForm");
|
||||
const searchForm = $("#searchForm");
|
||||
const queryInput = $("#query");
|
||||
const levelSelect = $("#level");
|
||||
const logScroll = $("#logScroll");
|
||||
|
||||
// toolbar
|
||||
const btnLive = $("#btn-live");
|
||||
const btnAuto = $("#btn-autoscroll");
|
||||
const btnWrap = $("#btn-wrap");
|
||||
const fontSelect = $("#font-size");
|
||||
const btnCopy = $("#btn-copy");
|
||||
const btnDownload = $("#btn-download");
|
||||
|
||||
// konfiguracja
|
||||
const POLL_MS = 1000;
|
||||
const ORDER_NEWEST_FIRST = true; // najnowsze na GÓRZE (backend już odwraca)
|
||||
const TOL = 40; // tolerancja (px) od krawędzi, aby uznać, że jesteśmy „przy krawędzi”
|
||||
|
||||
// state
|
||||
let selectedLevel = levelSelect ? levelSelect.value : "INFO";
|
||||
let pollTimer = null;
|
||||
let live = true;
|
||||
|
||||
// autoscroll: ON domyślnie
|
||||
let autoScroll = true;
|
||||
// gdy użytkownik ręcznie wyłączy — blokujemy automatyczne włączanie
|
||||
let autoLockByUser = false;
|
||||
// gdy autoscroll wyłączył się przez przewinięcie — możemy go automatycznie przywracać
|
||||
let autoDisabledBy = null; // null | 'scroll' | 'user'
|
||||
|
||||
let wrapped = false;
|
||||
let lastPayload = "";
|
||||
let debounceTimer;
|
||||
|
||||
function setUrlParam(key, val) {
|
||||
const url = new URL(window.location.href);
|
||||
if (val === "" || val == null) url.searchParams.delete(key);
|
||||
else url.searchParams.set(key, val);
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
function highlight() {
|
||||
if (window.hljs && typeof window.hljs.highlightElement === "function") {
|
||||
window.hljs.highlightElement(logContainer);
|
||||
}
|
||||
}
|
||||
|
||||
function setEmptyState(on) {
|
||||
$("#logEmpty")?.classList.toggle("d-none", !on);
|
||||
logContainer.classList.toggle("d-none", on);
|
||||
}
|
||||
|
||||
function refreshBtnLive() {
|
||||
if (!btnLive) return;
|
||||
btnLive.classList.toggle("btn-outline-light", live);
|
||||
btnLive.classList.toggle("btn-danger", !live);
|
||||
btnLive.textContent = live
|
||||
? (btnLive.dataset.onText || "Live ON")
|
||||
: (btnLive.dataset.offText || "Live OFF");
|
||||
}
|
||||
|
||||
function refreshBtnAuto() {
|
||||
if (!btnAuto) return;
|
||||
btnAuto.classList.toggle("btn-outline-secondary", !autoScroll);
|
||||
btnAuto.classList.toggle("btn-light", autoScroll);
|
||||
btnAuto.textContent = autoScroll ? "Auto-scroll" : "Auto-scroll (OFF)";
|
||||
}
|
||||
|
||||
function scrollToEdgeIfNeeded() {
|
||||
if (!autoScroll) return;
|
||||
if (ORDER_NEWEST_FIRST) {
|
||||
// najnowsze na górze -> krawędź to top
|
||||
logScroll.scrollTop = 0;
|
||||
} else {
|
||||
// klasyczny tail -> krawędź to dół
|
||||
logScroll.scrollTop = logScroll.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function applyText(content) {
|
||||
lastPayload = content || "";
|
||||
setEmptyState(!lastPayload.length);
|
||||
logContainer.textContent = lastPayload;
|
||||
highlight();
|
||||
|
||||
// tylko gdy autoscroll jest aktywny, przeskakujemy do krawędzi
|
||||
scrollToEdgeIfNeeded();
|
||||
}
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch (_) { /* fallback below */ }
|
||||
|
||||
// Fallback dla HTTP/nieobsługiwanych środowisk
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.top = "-1000px";
|
||||
ta.style.left = "-1000px";
|
||||
ta.setAttribute("readonly", "");
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
ta.setSelectionRange(0, ta.value.length);
|
||||
let ok = false;
|
||||
try {
|
||||
ok = document.execCommand("copy");
|
||||
} catch (_) {
|
||||
ok = false;
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function loadLogs({ silent = false } = {}) {
|
||||
const query = queryInput ? queryInput.value : "";
|
||||
const url = `/logs-data?level=${encodeURIComponent(selectedLevel)}&query=${encodeURIComponent(query)}`;
|
||||
return fetch(url)
|
||||
.then(res => res.ok ? res.json() : Promise.reject(res.status))
|
||||
.then(data => {
|
||||
// backend już zwraca newest-first; nie odwracaj kolejności
|
||||
const lines = Array.isArray(data.logs) ? data.logs.slice() : [];
|
||||
const text = lines.join("\n");
|
||||
applyText(text);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!silent) window.showToast?.({ text: "Błąd ładowania logów", variant: "danger" });
|
||||
});
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling();
|
||||
if (!live) return;
|
||||
pollTimer = setInterval(() => loadLogs({ silent: true }), POLL_MS);
|
||||
}
|
||||
function stopPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
|
||||
// wyszukiwarka — enter lub debounce 400ms
|
||||
searchForm?.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
setUrlParam("query", queryInput.value || "");
|
||||
loadLogs();
|
||||
});
|
||||
queryInput?.addEventListener("input", () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
setUrlParam("query", queryInput.value || "");
|
||||
loadLogs({ silent: true });
|
||||
}, 400);
|
||||
});
|
||||
|
||||
// --- TOOLBAR ---
|
||||
btnLive?.addEventListener("click", () => {
|
||||
live = !live;
|
||||
refreshBtnLive();
|
||||
window.showToast?.({
|
||||
text: live ? "Live tail: włączony" : "Live tail: wyłączony",
|
||||
variant: live ? "dark" : "warning"
|
||||
});
|
||||
live ? startPolling() : stopPolling();
|
||||
});
|
||||
refreshBtnLive();
|
||||
|
||||
btnAuto?.addEventListener("click", () => {
|
||||
// ręczne przełączenie blokuje/odblokowuje auto włączanie
|
||||
autoScroll = !autoScroll;
|
||||
autoLockByUser = !autoScroll; // jeśli wyłączasz przyciskiem — zablokuj auto re-enable
|
||||
autoDisabledBy = autoScroll ? null : "user";
|
||||
refreshBtnAuto();
|
||||
|
||||
window.showToast?.({
|
||||
text: autoScroll ? "Auto-scroll: ON" : "Auto-scroll: OFF (ręcznie)",
|
||||
variant: "info"
|
||||
});
|
||||
|
||||
if (autoScroll) {
|
||||
// po włączeniu — przeskocz do krawędzi
|
||||
scrollToEdgeIfNeeded();
|
||||
}
|
||||
});
|
||||
refreshBtnAuto();
|
||||
|
||||
btnWrap?.addEventListener("click", () => {
|
||||
wrapped = !wrapped;
|
||||
btnWrap.classList.toggle("btn-outline-secondary", !wrapped);
|
||||
btnWrap.classList.toggle("btn-light", wrapped);
|
||||
logContainer.style.whiteSpace = wrapped ? "pre-wrap" : "pre";
|
||||
});
|
||||
|
||||
fontSelect?.addEventListener("change", () => {
|
||||
logContainer.style.fontSize = fontSelect.value;
|
||||
});
|
||||
|
||||
btnCopy?.addEventListener("click", async () => {
|
||||
const ok = await copyToClipboard(lastPayload || "");
|
||||
window.showToast?.({
|
||||
text: ok ? "Skopiowano log do schowka." : "Nie udało się skopiować.",
|
||||
variant: ok ? "success" : "danger"
|
||||
});
|
||||
});
|
||||
|
||||
btnDownload?.addEventListener("click", () => {
|
||||
const blob = new Blob([lastPayload || ""], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = `app.log.view.txt`; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// --- SMART AUTOSCROLL GUARD ---
|
||||
// 1) auto-wyłączenie gdy oddalimy się od krawędzi
|
||||
// 2) auto-włączenie gdy wrócimy do krawędzi, ale tylko jeśli nie zablokowano przyciskiem
|
||||
let scrollTicking = false; // micro-raf guard
|
||||
logScroll.addEventListener("scroll", () => {
|
||||
if (scrollTicking) return;
|
||||
scrollTicking = true;
|
||||
requestAnimationFrame(() => {
|
||||
scrollTicking = false;
|
||||
|
||||
if (ORDER_NEWEST_FIRST) {
|
||||
// krawędź to góra (top)
|
||||
const atTop = logScroll.scrollTop <= TOL;
|
||||
const farFromTop = logScroll.scrollTop > TOL;
|
||||
|
||||
// 1) gdy uciekasz od góry — wyłącz auto, jeśli było włączone
|
||||
if (farFromTop && autoScroll) {
|
||||
autoScroll = false;
|
||||
autoDisabledBy = "scroll";
|
||||
refreshBtnAuto();
|
||||
window.showToast?.({ text: "Auto-scroll: OFF (przewijanie)", variant: "info" });
|
||||
}
|
||||
|
||||
// 2) gdy wracasz na samą górę — automatycznie włącz, jeśli nie ma blokady usera
|
||||
if (atTop && !autoScroll && !autoLockByUser && autoDisabledBy === "scroll") {
|
||||
autoScroll = true;
|
||||
autoDisabledBy = null;
|
||||
refreshBtnAuto();
|
||||
// upewnij się, że trzymamy top
|
||||
logScroll.scrollTop = 0;
|
||||
window.showToast?.({ text: "Auto-scroll: ON (powrót na górę)", variant: "info" });
|
||||
}
|
||||
} else {
|
||||
// klasyczny tail — krawędź to dół
|
||||
const distanceFromBottom = logScroll.scrollHeight - logScroll.clientHeight - logScroll.scrollTop;
|
||||
const atBottom = distanceFromBottom <= TOL;
|
||||
const farFromBottom = distanceFromBottom > TOL;
|
||||
|
||||
if (farFromBottom && autoScroll) {
|
||||
autoScroll = false;
|
||||
autoDisabledBy = "scroll";
|
||||
refreshBtnAuto();
|
||||
window.showToast?.({ text: "Auto-scroll: OFF (przewijanie)", variant: "info" });
|
||||
}
|
||||
if (atBottom && !autoScroll && !autoLockByUser && autoDisabledBy === "scroll") {
|
||||
autoScroll = true;
|
||||
autoDisabledBy = null;
|
||||
refreshBtnAuto();
|
||||
logScroll.scrollTop = logScroll.scrollHeight;
|
||||
window.showToast?.({ text: "Auto-scroll: ON (powrót na dół)", variant: "info" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { passive: true });
|
||||
|
||||
// Start
|
||||
loadLogs().then(() => startPolling());
|
||||
|
||||
// sprzątanie
|
||||
window.addEventListener("beforeunload", () => {
|
||||
stopPolling();
|
||||
});
|
||||
})();
|
||||
16
static/js/set_log_level.js
Normal file
16
static/js/set_log_level.js
Normal file
@@ -0,0 +1,16 @@
|
||||
document.getElementById('setLogLevelForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const level = document.getElementById('appLogLevel').value;
|
||||
fetch(SET_LOG_LEVEL_URL, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: 'level=' + encodeURIComponent(level)
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
window.showToast({ text: data.message, variant: 'success' });
|
||||
})
|
||||
.catch(() => {
|
||||
window.showToast({ text: "Błąd podczas zmiany poziomu", variant: "error" });
|
||||
});
|
||||
});
|
||||
72
static/js/stats.js
Normal file
72
static/js/stats.js
Normal file
@@ -0,0 +1,72 @@
|
||||
(function () {
|
||||
const $ = (s, r = document) => r.querySelector(s);
|
||||
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
|
||||
|
||||
// Kopiuj: cały widok
|
||||
$('#btn-copy-overview')?.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(document.body.innerText);
|
||||
window.showToast?.({ text: 'Skopiowano podsumowanie.', variant: 'success' });
|
||||
} catch {
|
||||
window.showToast?.({ text: 'Nie udało się skopiować.', variant: 'danger' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eksport CSV z DOM (GEO + REASONS)
|
||||
$('#btn-export-overview')?.addEventListener('click', () => {
|
||||
const lines = [];
|
||||
lines.push('section,key,count,percent');
|
||||
|
||||
// GEO: z tabeli
|
||||
$$('#geo-table tbody tr').forEach(tr => {
|
||||
const tds = tr.querySelectorAll('td');
|
||||
if (tds.length !== 3) return;
|
||||
const country = tds[0]?.innerText.trim();
|
||||
const percent = (tds[1]?.querySelector('.text-secondary')?.innerText.trim() || '').replace('%', '');
|
||||
const count = tds[2]?.innerText.trim();
|
||||
if (country) lines.push(`geo,"${country.replace(/"/g, '""')}",${count},${percent}`);
|
||||
});
|
||||
|
||||
// REASONS: z listy
|
||||
$$('.card:has(.card-header:contains("Przyczyny banów")) .list-group-item').forEach(li => {
|
||||
const label = li.querySelector('.text-truncate')?.getAttribute('title') || li.querySelector('.text-truncate')?.innerText || '';
|
||||
const meta = li.querySelector('.small.text-secondary')?.innerText || ''; // "123 (45.6%)"
|
||||
const m = meta.match(/(\d+)\s*\(([\d.,]+)%\)/);
|
||||
const count = m ? m[1] : '';
|
||||
const percent = m ? m[2].replace(',', '.') : '';
|
||||
if (label) lines.push(`reason,"${label.replace(/"/g, '""')}",${count},${percent}`);
|
||||
});
|
||||
|
||||
const csv = lines.join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = 'stats_overview.csv'; a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Polyfill :contains dla selektora użytego wyżej (prosty, lokalny)
|
||||
(function addContainsPseudo() {
|
||||
const _matches = Element.prototype.matches;
|
||||
if (!document.querySelector(':contains(dummy)')) {
|
||||
const oldQuerySelectorAll = Document.prototype.querySelectorAll;
|
||||
Document.prototype.querySelectorAll = function (sel) {
|
||||
if (!sel.includes(':contains(')) return oldQuerySelectorAll.call(this, sel);
|
||||
const m = sel.match(/^(.*):has\(\.card-header:contains\("([^"]+)"\)\)\s*(.*)$/);
|
||||
if (m) {
|
||||
const [, pre, text, post] = m;
|
||||
return $$(pre + ' .card').filter(card => {
|
||||
return card.querySelector('.card-header')?.textContent.includes(text);
|
||||
}).flatMap(card => card.querySelectorAll(post || ''));
|
||||
}
|
||||
return oldQuerySelectorAll.call(this, sel);
|
||||
};
|
||||
Element.prototype.matches = function (sel) {
|
||||
if (!sel.includes(':contains(')) return _matches.call(this, sel);
|
||||
const m = sel.match(/^:contains\("([^"]+)"\)$/);
|
||||
if (m) return this.textContent.includes(m[1]);
|
||||
return _matches.call(this, sel);
|
||||
};
|
||||
}
|
||||
})();
|
||||
})();
|
||||
35
static/js/toast.js
Normal file
35
static/js/toast.js
Normal file
@@ -0,0 +1,35 @@
|
||||
(function () {
|
||||
const toastEl = document.getElementById('appToast');
|
||||
const bodyEl = document.getElementById('appToastBody');
|
||||
|
||||
const variants = {
|
||||
success: 'text-bg-success',
|
||||
error: 'text-bg-danger',
|
||||
danger: 'text-bg-danger',
|
||||
warning: 'text-bg-warning',
|
||||
info: 'text-bg-info',
|
||||
dark: 'text-bg-dark'
|
||||
};
|
||||
|
||||
function setVariant(el, variant) {
|
||||
Object.values(variants).forEach(v => el.classList.remove(v));
|
||||
el.classList.add(variants[variant] || variants.dark);
|
||||
}
|
||||
|
||||
window.showToast = function ({ text = 'Gotowe.', variant = 'dark' } = {}) {
|
||||
if (bodyEl) bodyEl.textContent = text;
|
||||
setVariant(toastEl, variant);
|
||||
const t = new bootstrap.Toast(toastEl);
|
||||
t.show();
|
||||
};
|
||||
|
||||
const flashNode = document.getElementById('flash-data');
|
||||
if (flashNode) {
|
||||
try {
|
||||
const flashes = JSON.parse(flashNode.dataset.flashes || '[]');
|
||||
flashes.forEach(([category, message]) => {
|
||||
window.showToast({ text: message, variant: category });
|
||||
});
|
||||
} catch (_) { }
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user