ngxstat/templates/index.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>