718 lines
29 KiB
HTML
718 lines
29 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>ngxstat Reports</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
|
|
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.4/css/jquery.dataTables.min.css">
|
|
</head>
|
|
<body class="section">
|
|
<div class="container">
|
|
<h1 class="title">ngxstat Reports</h1>
|
|
|
|
<div class="tabs is-toggle" id="report-tabs">
|
|
<ul>
|
|
<li class="is-active" data-tab="recent"><a>Recent</a></li>
|
|
<li data-tab="trends"><a>Trends</a></li>
|
|
<li data-tab="breakdown"><a>Breakdown</a></li>
|
|
<li data-tab="analysis"><a>Analysis</a></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div id="controls" class="field is-grouped is-align-items-center mb-4" style="position: sticky; top: 0; background: white; z-index: 2; padding: 0.5rem 0;">
|
|
<!-- Hidden native interval control kept for compatibility and availability probing -->
|
|
<div id="interval-control" class="control has-icons-left is-hidden">
|
|
<div class="select is-small">
|
|
<select id="interval-select">
|
|
{% for interval in intervals %}
|
|
<option value="{{ interval }}">{{ interval.title() }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<span class="icon is-small is-left"><img src="icons/clock.svg" alt="Interval"></span>
|
|
</div>
|
|
<div id="domain-control" class="control has-icons-left is-hidden">
|
|
<div class="select is-small">
|
|
<select id="domain-select">
|
|
<option value="">All Domains</option>
|
|
{% for domain in domains %}
|
|
<option value="{{ domain }}">{{ domain }}</option>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<span class="icon is-small is-left"><img src="icons/server.svg" alt="Domain"></span>
|
|
</div>
|
|
<!-- Unified Time control: selects both range and sensible grouping -->
|
|
<div id="time-control" class="control has-icons-left is-hidden">
|
|
<div class="select is-small">
|
|
<select id="time-select">
|
|
<option value="1h">Last hour</option>
|
|
<option value="24h">Last 24 hours</option>
|
|
<option value="7d" selected>Last 7 days</option>
|
|
<option value="30d">Last 30 days</option>
|
|
<option value="12w">Last 12 weeks</option>
|
|
<option value="12m">Last 12 months</option>
|
|
<option value="all">All time</option>
|
|
</select>
|
|
</div>
|
|
<span class="icon is-small is-left"><img src="icons/clock.svg" alt="Time"></span>
|
|
</div>
|
|
<div id="smooth-control" class="control is-hidden">
|
|
<label class="checkbox is-small">
|
|
<input type="checkbox" id="smooth-toggle"> Smooth error rate
|
|
</label>
|
|
</div>
|
|
<div id="mode-percent-control" class="control is-hidden">
|
|
<label class="checkbox is-small" title="Show values as a percentage of the total, instead of raw counts.">
|
|
<input type="checkbox" id="percent-toggle"> Percent mode
|
|
</label>
|
|
</div>
|
|
<div id="mode-group-control" class="control is-hidden">
|
|
<label class="checkbox is-small" title="Combine small categories into an 'Other' slice to declutter charts.">
|
|
<input type="checkbox" id="group-toggle" checked> Group small into Other
|
|
</label>
|
|
</div>
|
|
<div id="exclude-uncached-control" class="control is-hidden">
|
|
<label class="checkbox is-small" title="Hide uncached entries (cache status '-') from cache status distributions.">
|
|
<input type="checkbox" id="exclude-uncached-toggle" checked> Exclude “-”
|
|
</label>
|
|
</div>
|
|
<div id="reset-control" class="control">
|
|
<button id="reset-view" class="button is-small is-light">Reset view</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="recent-section">
|
|
<div id="overview" class="box mb-5">
|
|
<h2 class="subtitle">Recent</h2>
|
|
<p>Total logs: <span id="stat-total">-</span></p>
|
|
<p>Date range: <span id="stat-start">-</span> to <span id="stat-end">-</span></p>
|
|
<p>Unique domains: <span id="stat-domains">-</span></p>
|
|
<p>Last generated: <span id="stat-generated">-</span></p>
|
|
<p>Generation time: <span id="stat-elapsed">-</span> seconds</p>
|
|
</div>
|
|
<!-- Two key distributions side-by-side on Recent -->
|
|
<div id="recent-row" class="columns"></div>
|
|
<div id="overview-reports"></div>
|
|
</div>
|
|
|
|
<div id="trends-section" class="is-hidden">
|
|
<div id="reports-trends"></div>
|
|
</div>
|
|
|
|
<div id="breakdown-section" class="is-hidden">
|
|
<div class="box mb-4">
|
|
<h2 class="subtitle">Breakdown</h2>
|
|
<p class="mb-2">Explore categorical distributions and detailed lists side-by-side. Use the options below to adjust how categories are shown.</p>
|
|
<ul style="margin-left: 1.2rem; list-style: disc;">
|
|
<li><strong>Percent mode</strong>: converts counts into percentages of the total for easier comparison.</li>
|
|
<li><strong>Group small into Other</strong>: combines tiny slices under a single “Other” category to declutter charts.</li>
|
|
<li><strong>Exclude “-”</strong>: hides uncached entries (cache status “-”) from cache status distributions.</li>
|
|
</ul>
|
|
</div>
|
|
<div id="reports-breakdown"></div>
|
|
</div>
|
|
|
|
<div id="analysis-section" class="is-hidden">
|
|
<div id="analysis-missing" class="box"></div>
|
|
<div id="analysis-cache" class="box mt-5"></div>
|
|
<div id="analysis-threats" class="box mt-5"></div>
|
|
</div>
|
|
</div>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.0/dist/jquery.min.js"></script>
|
|
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
|
|
<script type="module">
|
|
import {
|
|
newLoad,
|
|
abortLoad,
|
|
registerChart,
|
|
reset,
|
|
currentLoad,
|
|
sliceWindow,
|
|
excludeValues,
|
|
toPercent,
|
|
groupOthers,
|
|
movingAverage,
|
|
} from './chartManager.js';
|
|
const STATE_KEY = 'ngxstat-state-v2';
|
|
const intervalSelect = document.getElementById('interval-select');
|
|
const domainSelect = document.getElementById('domain-select');
|
|
const intervalControl = document.getElementById('interval-control');
|
|
const domainControl = document.getElementById('domain-control');
|
|
const timeControl = document.getElementById('time-control');
|
|
const timeSelect = document.getElementById('time-select');
|
|
const modePercentControl = document.getElementById('mode-percent-control');
|
|
const modeGroupControl = document.getElementById('mode-group-control');
|
|
const excludeUncachedControl = document.getElementById('exclude-uncached-control');
|
|
const smoothControl = document.getElementById('smooth-control');
|
|
const resetButton = document.getElementById('reset-view');
|
|
const tabs = document.querySelectorAll('#report-tabs li');
|
|
const sections = {
|
|
recent: document.getElementById('recent-section'),
|
|
trends: document.getElementById('trends-section'),
|
|
breakdown: document.getElementById('breakdown-section'),
|
|
analysis: document.getElementById('analysis-section')
|
|
};
|
|
const containers = {
|
|
recent: document.getElementById('overview-reports'),
|
|
trends: document.getElementById('reports-trends'),
|
|
breakdown: document.getElementById('reports-breakdown')
|
|
};
|
|
const recentRow = document.getElementById('recent-row');
|
|
const analysisElems = {
|
|
missing: document.getElementById('analysis-missing'),
|
|
cache: document.getElementById('analysis-cache'),
|
|
threats: document.getElementById('analysis-threats')
|
|
};
|
|
const totalElem = document.getElementById('stat-total');
|
|
const startElem = document.getElementById('stat-start');
|
|
const endElem = document.getElementById('stat-end');
|
|
const domainsElem = document.getElementById('stat-domains');
|
|
const generatedElem = document.getElementById('stat-generated');
|
|
const elapsedElem = document.getElementById('stat-elapsed');
|
|
|
|
// Extra controls
|
|
// Legacy window select kept for internal state only (not shown)
|
|
const windowSelect = document.getElementById('window-select');
|
|
|
|
// If legacy window select is not present in DOM, create a hidden one for code paths
|
|
// that still reference it.
|
|
(function ensureHiddenWindowSelect(){
|
|
if (!windowSelect) {
|
|
const hidden = document.createElement('select');
|
|
hidden.id = 'window-select';
|
|
hidden.classList.add('is-hidden');
|
|
// Supported values used by code
|
|
['1h','24h','7d','30d','12w','12m','all'].forEach(v => {
|
|
const o = document.createElement('option');
|
|
o.value = v; o.textContent = v;
|
|
hidden.appendChild(o);
|
|
});
|
|
document.body.appendChild(hidden);
|
|
}
|
|
})();
|
|
const percentToggle = document.getElementById('percent-toggle');
|
|
const groupToggle = document.getElementById('group-toggle');
|
|
const excludeUncachedToggle = document.getElementById('exclude-uncached-toggle');
|
|
const smoothToggle = document.getElementById('smooth-toggle');
|
|
|
|
let currentInterval = intervalSelect.value;
|
|
let currentDomain = domainSelect.value;
|
|
let currentTab = 'recent';
|
|
let currentWindow = windowSelect ? windowSelect.value : '7d'; // 1h, 24h, 7d, 30d, 12w, 12m, all
|
|
let modePercent = false;
|
|
let modeGroup = true;
|
|
let excludeUncached = true;
|
|
let smoothError = false;
|
|
let hadExplicitWindow = false; // URL or saved-state provided window
|
|
|
|
function saveState() {
|
|
try {
|
|
localStorage.setItem(STATE_KEY, JSON.stringify({
|
|
tab: currentTab,
|
|
interval: currentInterval,
|
|
domain: currentDomain,
|
|
window: currentWindow,
|
|
percent: modePercent ? 1 : 0,
|
|
group: modeGroup ? 1 : 0,
|
|
exclude_dash: excludeUncached ? 1 : 0,
|
|
smooth: smoothError ? 1 : 0,
|
|
}));
|
|
} catch {}
|
|
}
|
|
|
|
function loadSavedState() {
|
|
try {
|
|
const s = JSON.parse(localStorage.getItem(STATE_KEY) || '{}');
|
|
if (s.tab) currentTab = s.tab;
|
|
if (s.interval) currentInterval = s.interval;
|
|
if (s.domain !== undefined) currentDomain = s.domain;
|
|
if (s.window) { currentWindow = s.window; hadExplicitWindow = true; }
|
|
if (s.percent !== undefined) modePercent = !!Number(s.percent);
|
|
if (s.group !== undefined) modeGroup = !!Number(s.group);
|
|
if (s.exclude_dash !== undefined) excludeUncached = !!Number(s.exclude_dash);
|
|
if (s.smooth !== undefined) smoothError = !!Number(s.smooth);
|
|
} catch {}
|
|
}
|
|
|
|
function applyURLParams() {
|
|
const params = new URLSearchParams(location.search);
|
|
if (params.get('tab')) currentTab = params.get('tab');
|
|
if (params.get('interval')) currentInterval = params.get('interval');
|
|
if (params.get('domain') !== null) currentDomain = params.get('domain') || '';
|
|
if (params.get('window')) { currentWindow = params.get('window'); hadExplicitWindow = true; }
|
|
if (params.get('percent') !== null) modePercent = params.get('percent') === '1';
|
|
if (params.get('group') !== null) modeGroup = params.get('group') === '1';
|
|
if (params.get('exclude_dash') !== null) excludeUncached = params.get('exclude_dash') === '1';
|
|
if (params.get('smooth') !== null) smoothError = params.get('smooth') === '1';
|
|
}
|
|
|
|
function updateURL() {
|
|
const params = new URLSearchParams();
|
|
params.set('tab', currentTab);
|
|
params.set('interval', currentInterval);
|
|
if (currentDomain) params.set('domain', currentDomain);
|
|
params.set('window', currentWindow);
|
|
params.set('percent', modePercent ? '1' : '0');
|
|
params.set('group', modeGroup ? '1' : '0');
|
|
params.set('exclude_dash', excludeUncached ? '1' : '0');
|
|
params.set('smooth', smoothError ? '1' : '0');
|
|
const newUrl = `${location.pathname}?${params.toString()}`;
|
|
history.replaceState(null, '', newUrl);
|
|
saveState();
|
|
}
|
|
|
|
function bucketsForWindow(win, interval) {
|
|
switch (win) {
|
|
case '1h': return interval === 'hourly' ? 1 : 'all';
|
|
case '24h': return interval === 'hourly' ? 24 : 'all';
|
|
case '7d': return interval === 'daily' ? 7 : 'all';
|
|
case '30d': return interval === 'daily' ? 30 : 'all';
|
|
case '12w': return interval === 'weekly' ? 12 : 'all';
|
|
case '12m': return interval === 'monthly' ? 12 : 'all';
|
|
default: return 'all';
|
|
}
|
|
}
|
|
|
|
function availableIntervals() {
|
|
try {
|
|
return Array.from(intervalSelect ? intervalSelect.options : []).map(o => o.value);
|
|
} catch { return []; }
|
|
}
|
|
|
|
function pickIntervalForWindow(win) {
|
|
const avail = availableIntervals();
|
|
const pref = (list) => list.find(x => avail.includes(x));
|
|
switch (win) {
|
|
case '1h':
|
|
case '24h':
|
|
return pref(['hourly','daily','weekly','monthly']) || (avail[0] || 'daily');
|
|
case '7d':
|
|
case '30d':
|
|
return pref(['daily','weekly','monthly','hourly']) || (avail[0] || 'daily');
|
|
case '12w':
|
|
return pref(['weekly','daily','monthly']) || (avail[0] || 'weekly');
|
|
case '12m':
|
|
return pref(['monthly','weekly','daily']) || (avail[0] || 'monthly');
|
|
default:
|
|
// all time: favor coarser buckets if available
|
|
return pref(['monthly','weekly','daily','hourly']) || (avail[0] || 'weekly');
|
|
}
|
|
}
|
|
|
|
function applyTimePreset(win) {
|
|
currentWindow = win;
|
|
currentInterval = pickIntervalForWindow(win);
|
|
if (intervalSelect) intervalSelect.value = currentInterval;
|
|
const winSel = document.getElementById('window-select');
|
|
if (winSel) winSel.value = currentWindow;
|
|
}
|
|
|
|
function initReport(token, rep, base) {
|
|
fetch(base + '/' + rep.json, { signal: token.controller.signal })
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (token !== currentLoad) return;
|
|
const bucketFields = rep.buckets || [rep.bucket || 'bucket'];
|
|
const labels = Array.isArray(rep.bucket_label)
|
|
? rep.bucket_label
|
|
: [rep.bucket_label || 'Bucket'];
|
|
if (rep.chart === 'table') {
|
|
const rows = data.map(x => bucketFields.map(f => x[f]).concat(x.value));
|
|
const columns = labels.map(l => ({ title: l }));
|
|
columns.push({ title: 'Value' });
|
|
const table = new DataTable('#table-' + rep.name, {
|
|
data: rows,
|
|
columns: columns
|
|
});
|
|
registerChart(token, rep.name, table);
|
|
return;
|
|
}
|
|
|
|
// Transform pipeline (client-only)
|
|
let transformed = data.slice();
|
|
const bucketField = bucketFields[0];
|
|
const isTimeSeries = bucketField === 'time_bucket';
|
|
// Exclusions (per-report) and explicit uncached toggle for cache_status
|
|
if (rep.exclude_values && rep.exclude_values.length) {
|
|
transformed = excludeValues(transformed, bucketField, rep.exclude_values);
|
|
}
|
|
if (excludeUncached && bucketField === 'cache_status') {
|
|
transformed = excludeValues(transformed, bucketField, ['-']);
|
|
}
|
|
// Windowing for time series
|
|
if (isTimeSeries) {
|
|
// Only apply windowing if report supports current window (if constrained)
|
|
const supported = Array.isArray(rep.windows_supported) ? rep.windows_supported : null;
|
|
const canWindow = !supported || supported.includes(currentWindow);
|
|
if (canWindow) {
|
|
const n = bucketsForWindow(currentWindow, currentInterval);
|
|
transformed = sliceWindow(transformed, n);
|
|
}
|
|
}
|
|
// Distributions: percent + group small
|
|
const isDistribution = ['pie', 'polarArea', 'doughnut', 'donut'].includes(rep.chart);
|
|
if (isDistribution) {
|
|
if (modeGroup) {
|
|
const thr = (typeof rep.group_others_threshold === 'number') ? rep.group_others_threshold : 0.03;
|
|
transformed = groupOthers(transformed, bucketField, 'value', thr, 'Other');
|
|
}
|
|
if (modePercent) {
|
|
transformed = toPercent(transformed, 'value');
|
|
}
|
|
}
|
|
// Relabel '-' to 'Uncached' for cache_status distributions
|
|
if (bucketField === 'cache_status') {
|
|
transformed = transformed.map(row => ({
|
|
...row,
|
|
[bucketField]: row[bucketField] === '-' ? 'Uncached' : row[bucketField]
|
|
}));
|
|
}
|
|
|
|
const labelsArr = transformed.map(x => x[bucketField]);
|
|
let values = transformed.map(x => x.value);
|
|
const chartType = rep.chart === 'stackedBar' ? 'bar' : rep.chart;
|
|
const options = { scales: { y: { beginAtZero: true } } };
|
|
let datasets = [];
|
|
if (rep.chart === 'stackedBar') {
|
|
options.scales.x = { stacked: true };
|
|
options.scales.y = options.scales.y || {};
|
|
options.scales.y.stacked = true;
|
|
// Build multiple series from columns (exclude bucket & total)
|
|
const keys = transformed.length ? Object.keys(transformed[0]).filter(k => k !== bucketField && k !== 'total') : [];
|
|
const palette = rep.colors || rep.palette || [
|
|
'#3273dc', '#23d160', '#ffdd57', '#ff3860', '#7957d5', '#363636'
|
|
];
|
|
datasets = keys.map((k, i) => ({
|
|
label: k,
|
|
data: transformed.map(r => Number(r[k]) || 0),
|
|
backgroundColor: palette[i % palette.length],
|
|
borderColor: palette[i % palette.length],
|
|
borderWidth: 1,
|
|
fill: false,
|
|
}));
|
|
} else {
|
|
const dataset = {
|
|
label: rep.label,
|
|
data: values,
|
|
borderWidth: 1,
|
|
fill: rep.chart !== 'bar' && rep.chart !== 'stackedBar'
|
|
};
|
|
if (rep.colors) {
|
|
dataset.backgroundColor = rep.colors;
|
|
dataset.borderColor = rep.colors;
|
|
} else if (rep.palette) {
|
|
dataset.backgroundColor = rep.palette;
|
|
dataset.borderColor = rep.palette;
|
|
} else if (rep.color) {
|
|
dataset.backgroundColor = rep.color;
|
|
dataset.borderColor = rep.color;
|
|
} else {
|
|
dataset.backgroundColor = 'rgba(54, 162, 235, 0.5)';
|
|
dataset.borderColor = 'rgba(54, 162, 235, 1)';
|
|
}
|
|
// Optional smoothing for error_rate
|
|
if (rep.name === 'error_rate' && smoothError) {
|
|
dataset.data = movingAverage(values, 3);
|
|
dataset.label = rep.label + ' (smoothed)';
|
|
}
|
|
datasets = [dataset];
|
|
}
|
|
const chart = new Chart(document.getElementById('chart-' + rep.name), {
|
|
type: chartType,
|
|
data: {
|
|
labels: labelsArr,
|
|
datasets
|
|
},
|
|
options: options
|
|
});
|
|
registerChart(token, rep.name, chart);
|
|
});
|
|
}
|
|
|
|
function loadStats() {
|
|
fetch('global/stats.json')
|
|
.then(r => r.json())
|
|
.then(stats => {
|
|
totalElem.textContent = stats.total_logs;
|
|
startElem.textContent = stats.start_date;
|
|
endElem.textContent = stats.end_date;
|
|
domainsElem.textContent = stats.unique_domains;
|
|
generatedElem.textContent = stats.generated_at || '-';
|
|
elapsedElem.textContent =
|
|
stats.generation_seconds !== undefined ? stats.generation_seconds : '-';
|
|
});
|
|
}
|
|
|
|
// Reset helpers managed by chartManager
|
|
|
|
function loadReports() {
|
|
let path;
|
|
let container = containers[currentTab];
|
|
if (currentTab === 'recent') {
|
|
path = 'global';
|
|
} else {
|
|
path = currentDomain ? ('domains/' + encodeURIComponent(currentDomain) + '/' + currentInterval) : currentInterval;
|
|
}
|
|
|
|
// Clear the top row on each load of Recent
|
|
if (currentTab === 'recent' && recentRow) {
|
|
recentRow.innerHTML = '';
|
|
}
|
|
|
|
const token = newLoad(container);
|
|
|
|
fetch(path + '/reports.json', { signal: token.controller.signal })
|
|
.then(r => r.json())
|
|
.then(reports => {
|
|
if (token !== currentLoad) return;
|
|
const isDistributionType = t => ['pie','polarArea','doughnut','donut'].includes(t);
|
|
const filtered = reports.filter(rep => {
|
|
if (currentTab === 'recent') return true;
|
|
if (currentTab === 'trends') return rep.chart !== 'table' && !isDistributionType(rep.chart);
|
|
if (currentTab === 'breakdown') return isDistributionType(rep.chart) || rep.chart === 'table';
|
|
return true;
|
|
});
|
|
// If no explicit window was given (URL or saved state), honor first report's default
|
|
if (!hadExplicitWindow) {
|
|
const withDefault = filtered.find(r => r.window_default);
|
|
if (withDefault && typeof withDefault.window_default === 'string') {
|
|
currentWindow = withDefault.window_default;
|
|
windowSelect.value = currentWindow;
|
|
updateURL();
|
|
}
|
|
}
|
|
filtered.forEach(rep => {
|
|
fetch(path + '/' + rep.html, { signal: token.controller.signal })
|
|
.then(r => r.text())
|
|
.then(html => {
|
|
if (token !== currentLoad) return;
|
|
// On Recent tab, render Cache Status and HTTP Statuses side-by-side
|
|
const inTopRow = currentTab === 'recent' &&
|
|
(rep.name === 'cache_status_breakdown' || rep.name === 'status_distribution');
|
|
if (inTopRow && recentRow) {
|
|
const wrapped = `<div class="column is-half">${html}</div>`;
|
|
recentRow.insertAdjacentHTML('beforeend', wrapped);
|
|
} else {
|
|
container.insertAdjacentHTML('beforeend', html);
|
|
}
|
|
initReport(token, rep, path);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function loadAnalysis() {
|
|
analysisElems.missing.innerHTML = '<h2 class="subtitle">Missing Domains</h2>';
|
|
analysisElems.cache.innerHTML = '<h2 class="subtitle">Cache Suggestions</h2>';
|
|
analysisElems.threats.innerHTML = '<h2 class="subtitle">Threat Report</h2>';
|
|
|
|
fetch('analysis/missing_domains.json')
|
|
.then(r => r.json())
|
|
.then(list => {
|
|
if (list.length === 0) {
|
|
analysisElems.missing.insertAdjacentHTML('beforeend', '<p>None</p>');
|
|
return;
|
|
}
|
|
const items = list.map(d => `<li>${d}</li>`).join('');
|
|
analysisElems.missing.insertAdjacentHTML('beforeend', `<ul>${items}</ul>`);
|
|
});
|
|
|
|
fetch('analysis/cache_suggestions.json')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.length === 0) {
|
|
analysisElems.cache.insertAdjacentHTML('beforeend', '<p>No suggestions</p>');
|
|
return;
|
|
}
|
|
analysisElems.cache.insertAdjacentHTML('beforeend', '<table id="table-cache" class="table is-striped"></table>');
|
|
const rows = data.map(x => [x.host, x.path, x.misses]);
|
|
new DataTable('#table-cache', {
|
|
data: rows,
|
|
columns: [
|
|
{ title: 'Domain' },
|
|
{ title: 'Path' },
|
|
{ title: 'Misses' }
|
|
]
|
|
});
|
|
});
|
|
|
|
fetch('analysis/threat_report.json')
|
|
.then(r => r.json())
|
|
.then(rep => {
|
|
const hasData = rep.error_spikes?.length || rep.suspicious_agents?.length || rep.high_ip_requests?.length;
|
|
if (!hasData) {
|
|
analysisElems.threats.insertAdjacentHTML('beforeend', '<p>No threats detected</p>');
|
|
return;
|
|
}
|
|
if (rep.error_spikes && rep.error_spikes.length) {
|
|
analysisElems.threats.insertAdjacentHTML('beforeend', '<h3 class="subtitle is-6 mt-4">Error Spikes</h3><table id="table-errors" class="table is-striped"></table>');
|
|
const rows = rep.error_spikes.map(x => [x.host, x.recent_error_rate, x.previous_error_rate]);
|
|
new DataTable('#table-errors', {
|
|
data: rows,
|
|
columns: [
|
|
{ title: 'Domain' },
|
|
{ title: 'Recent %' },
|
|
{ title: 'Previous %' }
|
|
]
|
|
});
|
|
}
|
|
if (rep.suspicious_agents && rep.suspicious_agents.length) {
|
|
analysisElems.threats.insertAdjacentHTML('beforeend', '<h3 class="subtitle is-6 mt-4">Suspicious User Agents</h3><table id="table-agents" class="table is-striped"></table>');
|
|
const rows = rep.suspicious_agents.map(x => [x.user_agent, x.requests]);
|
|
new DataTable('#table-agents', {
|
|
data: rows,
|
|
columns: [
|
|
{ title: 'User Agent' },
|
|
{ title: 'Requests' }
|
|
]
|
|
});
|
|
}
|
|
if (rep.high_ip_requests && rep.high_ip_requests.length) {
|
|
analysisElems.threats.insertAdjacentHTML('beforeend', '<h3 class="subtitle is-6 mt-4">High IP Requests</h3><table id="table-ips" class="table is-striped"></table>');
|
|
const rows = rep.high_ip_requests.map(x => [x.ip, x.requests]);
|
|
new DataTable('#table-ips', {
|
|
data: rows,
|
|
columns: [
|
|
{ title: 'IP' },
|
|
{ title: 'Requests' }
|
|
]
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function switchTab(name) {
|
|
abortLoad(currentLoad);
|
|
Object.values(containers).forEach(reset);
|
|
currentTab = name;
|
|
tabs.forEach(tab => {
|
|
tab.classList.toggle('is-active', tab.dataset.tab === name);
|
|
});
|
|
Object.entries(sections).forEach(([key, section]) => {
|
|
section.classList.toggle('is-hidden', key !== name);
|
|
});
|
|
const showTime = name !== 'recent' && name !== 'analysis';
|
|
const showDomain = showTime;
|
|
// Always keep legacy interval control hidden; use unified time control
|
|
intervalControl.classList.add('is-hidden');
|
|
domainControl.classList.toggle('is-hidden', !showDomain);
|
|
timeControl.classList.toggle('is-hidden', !showTime);
|
|
// Only show percent/group/exclude toggles on Breakdown tab,
|
|
// and smoothing only on Trends tab
|
|
modePercentControl.classList.toggle('is-hidden', name !== 'breakdown');
|
|
modeGroupControl.classList.toggle('is-hidden', name !== 'breakdown');
|
|
excludeUncachedControl.classList.toggle('is-hidden', name !== 'breakdown');
|
|
smoothControl.classList.toggle('is-hidden', name !== 'trends');
|
|
updateURL();
|
|
if (name === 'recent') {
|
|
loadStats();
|
|
}
|
|
if (name === 'analysis') {
|
|
loadAnalysis();
|
|
} else {
|
|
loadReports();
|
|
}
|
|
}
|
|
|
|
if (intervalSelect) {
|
|
intervalSelect.addEventListener('change', () => {
|
|
currentInterval = intervalSelect.value;
|
|
abortLoad(currentLoad);
|
|
Object.values(containers).forEach(reset);
|
|
updateURL();
|
|
loadReports();
|
|
});
|
|
}
|
|
|
|
domainSelect.addEventListener('change', () => {
|
|
currentDomain = domainSelect.value;
|
|
abortLoad(currentLoad);
|
|
Object.values(containers).forEach(reset);
|
|
updateURL();
|
|
loadReports();
|
|
});
|
|
|
|
if (timeSelect) {
|
|
timeSelect.addEventListener('change', () => {
|
|
applyTimePreset(timeSelect.value);
|
|
abortLoad(currentLoad);
|
|
updateURL();
|
|
loadReports();
|
|
});
|
|
}
|
|
|
|
percentToggle.addEventListener('change', () => {
|
|
modePercent = percentToggle.checked;
|
|
abortLoad(currentLoad);
|
|
updateURL();
|
|
loadReports();
|
|
});
|
|
|
|
groupToggle.addEventListener('change', () => {
|
|
modeGroup = groupToggle.checked;
|
|
abortLoad(currentLoad);
|
|
updateURL();
|
|
loadReports();
|
|
});
|
|
|
|
excludeUncachedToggle.addEventListener('change', () => {
|
|
excludeUncached = excludeUncachedToggle.checked;
|
|
abortLoad(currentLoad);
|
|
updateURL();
|
|
loadReports();
|
|
});
|
|
|
|
smoothToggle.addEventListener('change', () => {
|
|
smoothError = smoothToggle.checked;
|
|
abortLoad(currentLoad);
|
|
updateURL();
|
|
loadReports();
|
|
});
|
|
|
|
tabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
switchTab(tab.dataset.tab);
|
|
});
|
|
});
|
|
resetButton.addEventListener('click', () => {
|
|
try {
|
|
localStorage.removeItem('ngxstat-state'); // clear legacy
|
|
localStorage.removeItem(STATE_KEY);
|
|
} catch {}
|
|
// Reset to hard defaults
|
|
currentTab = 'recent';
|
|
currentInterval = intervalSelect ? (intervalSelect.value = intervalSelect.options[0]?.value || currentInterval) : currentInterval;
|
|
currentDomain = domainSelect.value = '';
|
|
applyTimePreset('7d');
|
|
if (timeSelect) timeSelect.value = '7d';
|
|
modePercent = percentToggle.checked = false;
|
|
modeGroup = groupToggle.checked = true;
|
|
excludeUncached = excludeUncachedToggle.checked = true;
|
|
smoothError = smoothToggle.checked = false;
|
|
hadExplicitWindow = false;
|
|
switchTab(currentTab);
|
|
});
|
|
// Initialize state (URL -> localStorage -> defaults)
|
|
loadSavedState();
|
|
applyURLParams();
|
|
// Sync controls
|
|
if (intervalSelect) intervalSelect.value = currentInterval;
|
|
domainSelect.value = currentDomain;
|
|
// Sync unified time select based on state
|
|
if (timeSelect) {
|
|
const known = new Set(['1h','24h','7d','30d','12w','12m','all']);
|
|
const pick = known.has(currentWindow) ? currentWindow : 'all';
|
|
timeSelect.value = pick;
|
|
applyTimePreset(pick);
|
|
}
|
|
percentToggle.checked = modePercent;
|
|
groupToggle.checked = modeGroup;
|
|
excludeUncachedToggle.checked = excludeUncached;
|
|
smoothToggle.checked = smoothError;
|
|
// Show/hide controls based on active tab
|
|
switchTab(currentTab);
|
|
</script>
|
|
</body>
|
|
</html>
|