Phase 1 UX + JS transforms: tabs, windowing, percent/grouping, smoothing, stacked series, metadata pass-through, top_n

- Replace tabs with Recent/Trends/Distribution/Tables/Analysis and add sticky controls (interval, domain, window [default 7d], percent, group small, exclude '-' -> Uncached, smoothing toggle).

- Client-side transforms: time-window slicing, percent mode, group others (3%), per-report exclusions; stackedBar multi-series; moving average for error_rate.

- Generator: pass through optional UX metadata (windows_supported, window_default, group_others_threshold, exclude_values, top_n, stacked, palette) and enforce top_n LIMIT for table reports.

- Reports: add status_classes_timeseries and cache_status_timeseries; apply top_n=50 to heavy tables.

- Chart manager: add helpers (sliceWindow, excludeValues, toPercent, groupOthers, movingAverage).

- URL state + localStorage for context; per-tab filtering for Trends/Distribution/Tables.
This commit is contained in:
ngxstat-bot 2025-08-18 23:01:00 -05:00
commit fab91d2e04
4 changed files with 442 additions and 59 deletions

View file

@ -48,6 +48,7 @@
label: Top Domains label: Top Domains
icon: globe icon: globe
chart: table chart: table
top_n: 50
per_domain: false per_domain: false
bucket: domain bucket: domain
bucket_label: Domain bucket_label: Domain
@ -75,6 +76,7 @@
label: Top Paths label: Top Paths
icon: map icon: map
chart: table chart: table
top_n: 50
buckets: buckets:
- domain - domain
- path - path
@ -102,6 +104,7 @@
label: User Agents label: User Agents
icon: user icon: user
chart: table chart: table
top_n: 50
buckets: buckets:
- domain - domain
- user_agent - user_agent
@ -127,6 +130,7 @@
label: Referrers label: Referrers
icon: link icon: link
chart: table chart: table
top_n: 50
buckets: buckets:
- domain - domain
- referrer - referrer
@ -170,3 +174,40 @@
- "#209cee" - "#209cee"
- "#ffdd57" - "#ffdd57"
- "#f14668" - "#f14668"
# New time-series: status classes over time (stacked)
- name: status_classes_timeseries
label: Status Classes Over Time
icon: server
chart: stackedBar
bucket: time_bucket
bucket_label: Time
stacked: true
query: |
SELECT {bucket} AS time_bucket,
SUM(CASE WHEN status BETWEEN 200 AND 299 THEN 1 ELSE 0 END) AS "2xx",
SUM(CASE WHEN status BETWEEN 300 AND 399 THEN 1 ELSE 0 END) AS "3xx",
SUM(CASE WHEN status BETWEEN 400 AND 499 THEN 1 ELSE 0 END) AS "4xx",
SUM(CASE WHEN status BETWEEN 500 AND 599 THEN 1 ELSE 0 END) AS "5xx",
COUNT(*) AS total
FROM logs
GROUP BY time_bucket
ORDER BY time_bucket
# New time-series: cache status over time (compact Hit/Miss; exclude '-' by default)
- name: cache_status_timeseries
label: Cache Status Over Time
icon: archive
chart: stackedBar
bucket: time_bucket
bucket_label: Time
stacked: true
exclude_values: ["-"]
query: |
SELECT {bucket} AS time_bucket,
SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END) AS hit,
SUM(CASE WHEN cache_status = 'MISS' THEN 1 ELSE 0 END) AS miss,
COUNT(*) AS total
FROM logs
GROUP BY time_bucket
ORDER BY time_bucket

View file

@ -178,6 +178,16 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
name = definition["name"] name = definition["name"]
query = definition["query"].replace("{bucket}", bucket) query = definition["query"].replace("{bucket}", bucket)
query = query.replace("FROM logs", "FROM logs_view") query = query.replace("FROM logs", "FROM logs_view")
# Apply top_n limit for tables (performance-friendly), if configured
top_n = definition.get("top_n")
chart_type = definition.get("chart", "line")
if top_n and chart_type == "table":
try:
n = int(top_n)
if "LIMIT" not in query.upper():
query = f"{query}\nLIMIT {n}"
except Exception:
pass
cur.execute(query) cur.execute(query)
rows = cur.fetchall() rows = cur.fetchall()
headers = [c[0] for c in cur.description] headers = [c[0] for c in cur.description]
@ -203,6 +213,18 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
entry["color"] = definition["color"] entry["color"] = definition["color"]
if "colors" in definition: if "colors" in definition:
entry["colors"] = definition["colors"] entry["colors"] = definition["colors"]
# Optional UX metadata passthrough for frontend-only transforms
for key in (
"windows_supported",
"window_default",
"group_others_threshold",
"exclude_values",
"top_n",
"stacked",
"palette",
):
if key in definition:
entry[key] = definition[key]
_render_snippet(entry, out_dir) _render_snippet(entry, out_dir)
report_list.append(entry) report_list.append(entry)
@ -266,6 +288,16 @@ def _generate_global() -> None:
name = definition["name"] name = definition["name"]
query = definition["query"] query = definition["query"]
# Apply top_n limit for tables (performance-friendly), if configured
top_n = definition.get("top_n")
chart_type = definition.get("chart", "line")
if top_n and chart_type == "table":
try:
n = int(top_n)
if "LIMIT" not in query.upper():
query = f"{query}\nLIMIT {n}"
except Exception:
pass
cur.execute(query) cur.execute(query)
rows = cur.fetchall() rows = cur.fetchall()
headers = [c[0] for c in cur.description] headers = [c[0] for c in cur.description]
@ -291,6 +323,18 @@ def _generate_global() -> None:
entry["color"] = definition["color"] entry["color"] = definition["color"]
if "colors" in definition: if "colors" in definition:
entry["colors"] = definition["colors"] entry["colors"] = definition["colors"]
# Optional UX metadata passthrough for frontend-only transforms
for key in (
"windows_supported",
"window_default",
"group_others_threshold",
"exclude_values",
"top_n",
"stacked",
"palette",
):
if key in definition:
entry[key] = definition[key]
_render_snippet(entry, out_dir) _render_snippet(entry, out_dir)
report_list.append(entry) report_list.append(entry)

View file

@ -47,3 +47,63 @@ export function reset(container) {
}); });
container.innerHTML = ''; container.innerHTML = '';
} }
// ---- Lightweight client-side data helpers ----
// Slice last N rows from a time-ordered array
export function sliceWindow(data, n) {
if (!Array.isArray(data) || n === undefined || n === null) return data;
if (n === 'all') return data;
const count = Number(n);
if (!Number.isFinite(count) || count <= 0) return data;
return data.slice(-count);
}
// Exclude rows whose value in key is in excluded list
export function excludeValues(data, key, excluded = []) {
if (!excluded || excluded.length === 0) return data;
const set = new Set(excluded);
return data.filter(row => !set.has(row[key]));
}
// Compute percentages for categorical distributions (valueKey default 'value')
export function toPercent(data, valueKey = 'value') {
const total = data.reduce((s, r) => s + (Number(r[valueKey]) || 0), 0);
if (total <= 0) return data.map(r => ({ ...r }));
return data.map(r => ({ ...r, [valueKey]: (Number(r[valueKey]) || 0) * 100 / total }));
}
// Group categories with share < threshold into an 'Other' bucket.
export function groupOthers(data, bucketKey, valueKey = 'value', threshold = 0.03, otherLabel = 'Other') {
if (!Array.isArray(data) || data.length === 0) return data;
const total = data.reduce((s, r) => s + (Number(r[valueKey]) || 0), 0);
if (total <= 0) return data;
const major = [];
let other = 0;
for (const r of data) {
const v = Number(r[valueKey]) || 0;
if (total && v / total < threshold) {
other += v;
} else {
major.push({ ...r });
}
}
if (other > 0) major.push({ [bucketKey]: otherLabel, [valueKey]: other });
return major;
}
// Simple moving average over numeric array
export function movingAverage(series, span = 3) {
const n = Math.max(1, Number(span) || 1);
const out = [];
for (let i = 0; i < series.length; i++) {
const start = Math.max(0, i - n + 1);
let sum = 0, cnt = 0;
for (let j = start; j <= i; j++) {
const v = Number(series[j]);
if (Number.isFinite(v)) { sum += v; cnt++; }
}
out.push(cnt ? sum / cnt : null);
}
return out;
}

View file

@ -12,14 +12,15 @@
<div class="tabs is-toggle" id="report-tabs"> <div class="tabs is-toggle" id="report-tabs">
<ul> <ul>
<li class="is-active" data-tab="overview"><a>Overview</a></li> <li class="is-active" data-tab="recent"><a>Recent</a></li>
<li data-tab="all"><a>All Domains</a></li> <li data-tab="trends"><a>Trends</a></li>
<li data-tab="domain"><a>Per Domain</a></li> <li data-tab="distribution"><a>Distribution</a></li>
<li data-tab="tables"><a>Tables</a></li>
<li data-tab="analysis"><a>Analysis</a></li> <li data-tab="analysis"><a>Analysis</a></li>
</ul> </ul>
</div> </div>
<div id="controls" class="field is-grouped mb-4"> <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;">
<div id="interval-control" class="control has-icons-left is-hidden"> <div id="interval-control" class="control has-icons-left is-hidden">
<div class="select is-small"> <div class="select is-small">
<select id="interval-select"> <select id="interval-select">
@ -41,11 +42,43 @@
</div> </div>
<span class="icon is-small is-left"><img src="icons/server.svg" alt="Domain"></span> <span class="icon is-small is-left"><img src="icons/server.svg" alt="Domain"></span>
</div> </div>
<div id="window-control" class="control has-icons-left is-hidden">
<div class="select is-small">
<select id="window-select">
<option value="1h">Last 1h</option>
<option value="24h">Last 24h</option>
<option value="7d" selected>Last 7d</option>
<option value="30d">Last 30d</option>
<option value="all">All</option>
</select>
</div>
<span class="icon is-small is-left"><img src="icons/pulse.svg" alt="Window"></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">
<input type="checkbox" id="percent-toggle"> Percent mode
</label>
</div>
<div id="mode-group-control" class="control is-hidden">
<label class="checkbox is-small">
<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">
<input type="checkbox" id="exclude-uncached-toggle" checked> Exclude “-”
</label>
</div>
</div> </div>
<div id="overview-section"> <div id="recent-section">
<div id="overview" class="box mb-5"> <div id="overview" class="box mb-5">
<h2 class="subtitle">Overview</h2> <h2 class="subtitle">Recent</h2>
<p>Total logs: <span id="stat-total">-</span></p> <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>Date range: <span id="stat-start">-</span> to <span id="stat-end">-</span></p>
<p>Unique domains: <span id="stat-domains">-</span></p> <p>Unique domains: <span id="stat-domains">-</span></p>
@ -55,13 +88,17 @@
<div id="overview-reports"></div> <div id="overview-reports"></div>
</div> </div>
<div id="all-section" class="is-hidden"> <div id="trends-section" class="is-hidden">
<div id="reports-all"></div> <div id="reports-trends"></div>
</div> </div>
<div id="domain-section" class="is-hidden"> <div id="distribution-section" class="is-hidden">
<div id="reports-domain"></div> <div id="reports-distribution"></div>
</div> </div>
<div id="tables-section" class="is-hidden">
<div id="reports-tables"></div>
</div>
<div id="analysis-section" class="is-hidden"> <div id="analysis-section" class="is-hidden">
<div id="analysis-missing" class="box"></div> <div id="analysis-missing" class="box"></div>
@ -79,22 +116,34 @@
registerChart, registerChart,
reset, reset,
currentLoad, currentLoad,
sliceWindow,
excludeValues,
toPercent,
groupOthers,
movingAverage,
} from './chartManager.js'; } from './chartManager.js';
const intervalSelect = document.getElementById('interval-select'); const intervalSelect = document.getElementById('interval-select');
const domainSelect = document.getElementById('domain-select'); const domainSelect = document.getElementById('domain-select');
const intervalControl = document.getElementById('interval-control'); const intervalControl = document.getElementById('interval-control');
const domainControl = document.getElementById('domain-control'); const domainControl = document.getElementById('domain-control');
const windowControl = document.getElementById('window-control');
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 tabs = document.querySelectorAll('#report-tabs li'); const tabs = document.querySelectorAll('#report-tabs li');
const sections = { const sections = {
overview: document.getElementById('overview-section'), recent: document.getElementById('recent-section'),
all: document.getElementById('all-section'), trends: document.getElementById('trends-section'),
domain: document.getElementById('domain-section'), distribution: document.getElementById('distribution-section'),
tables: document.getElementById('tables-section'),
analysis: document.getElementById('analysis-section') analysis: document.getElementById('analysis-section')
}; };
const containers = { const containers = {
overview: document.getElementById('overview-reports'), recent: document.getElementById('overview-reports'),
all: document.getElementById('reports-all'), trends: document.getElementById('reports-trends'),
domain: document.getElementById('reports-domain') distribution: document.getElementById('reports-distribution'),
tables: document.getElementById('reports-tables')
}; };
const analysisElems = { const analysisElems = {
missing: document.getElementById('analysis-missing'), missing: document.getElementById('analysis-missing'),
@ -108,9 +157,87 @@
const generatedElem = document.getElementById('stat-generated'); const generatedElem = document.getElementById('stat-generated');
const elapsedElem = document.getElementById('stat-elapsed'); const elapsedElem = document.getElementById('stat-elapsed');
// Extra controls
const windowSelect = document.getElementById('window-select');
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 currentInterval = intervalSelect.value;
let currentDomain = domainSelect.value; let currentDomain = domainSelect.value;
let currentTab = 'overview'; let currentTab = 'recent';
let currentWindow = windowSelect.value; // 1h, 24h, 7d, 30d, all
let modePercent = false;
let modeGroup = true;
let excludeUncached = true;
let smoothError = false;
function saveState() {
try {
localStorage.setItem('ngxstat-state', 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('ngxstat-state') || '{}');
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;
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');
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';
default: return 'all';
}
}
function initReport(token, rep, base) { function initReport(token, rep, base) {
fetch(base + '/' + rep.json, { signal: token.controller.signal }) fetch(base + '/' + rep.json, { signal: token.controller.signal })
@ -133,36 +260,92 @@
return; return;
} }
// Transform pipeline (client-only)
let transformed = data.slice();
const bucketField = bucketFields[0]; const bucketField = bucketFields[0];
const labelsArr = data.map(x => x[bucketField]); const isTimeSeries = bucketField === 'time_bucket';
const values = data.map(x => x.value); // 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) {
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 chartType = rep.chart === 'stackedBar' ? 'bar' : rep.chart;
const options = { scales: { y: { beginAtZero: true } } }; const options = { scales: { y: { beginAtZero: true } } };
let datasets = [];
if (rep.chart === 'stackedBar') { if (rep.chart === 'stackedBar') {
options.scales.x = { stacked: true }; options.scales.x = { stacked: true };
options.scales.y = options.scales.y || {};
options.scales.y.stacked = true; options.scales.y.stacked = true;
} // Build multiple series from columns (exclude bucket & total)
const dataset = { const keys = transformed.length ? Object.keys(transformed[0]).filter(k => k !== bucketField && k !== 'total') : [];
label: rep.label, const palette = rep.colors || [
data: values, '#3273dc', '#23d160', '#ffdd57', '#ff3860', '#7957d5', '#363636'
borderWidth: 1, ];
fill: rep.chart !== 'bar' && rep.chart !== 'stackedBar' datasets = keys.map((k, i) => ({
}; label: k,
if (rep.colors) { data: transformed.map(r => Number(r[k]) || 0),
dataset.backgroundColor = rep.colors; backgroundColor: palette[i % palette.length],
dataset.borderColor = rep.colors; borderColor: palette[i % palette.length],
} else if (rep.color) { borderWidth: 1,
dataset.backgroundColor = rep.color; fill: false,
dataset.borderColor = rep.color; }));
} else { } else {
dataset.backgroundColor = 'rgba(54, 162, 235, 0.5)'; const dataset = {
dataset.borderColor = 'rgba(54, 162, 235, 1)'; 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.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), { const chart = new Chart(document.getElementById('chart-' + rep.name), {
type: chartType, type: chartType,
data: { data: {
labels: labelsArr, labels: labelsArr,
datasets: [dataset] datasets
}, },
options: options options: options
}); });
@ -188,21 +371,11 @@
function loadReports() { function loadReports() {
let path; let path;
let container; let container = containers[currentTab];
if (currentTab === 'overview') { if (currentTab === 'recent') {
path = 'global'; path = 'global';
container = containers.overview;
} else if (currentTab === 'all') {
path = currentInterval;
container = containers.all;
} else { } else {
container = containers.domain; path = currentDomain ? ('domains/' + encodeURIComponent(currentDomain) + '/' + currentInterval) : currentInterval;
if (!currentDomain) {
reset(container);
container.innerHTML = '<p>Select a domain</p>';
return;
}
path = 'domains/' + encodeURIComponent(currentDomain) + '/' + currentInterval;
} }
const token = newLoad(container); const token = newLoad(container);
@ -211,7 +384,15 @@
.then(r => r.json()) .then(r => r.json())
.then(reports => { .then(reports => {
if (token !== currentLoad) return; if (token !== currentLoad) return;
reports.forEach(rep => { 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 === 'distribution') return isDistributionType(rep.chart);
if (currentTab === 'tables') return rep.chart === 'table';
return true;
});
filtered.forEach(rep => {
fetch(path + '/' + rep.html, { signal: token.controller.signal }) fetch(path + '/' + rep.html, { signal: token.controller.signal })
.then(r => r.text()) .then(r => r.text())
.then(html => { .then(html => {
@ -313,9 +494,17 @@
Object.entries(sections).forEach(([key, section]) => { Object.entries(sections).forEach(([key, section]) => {
section.classList.toggle('is-hidden', key !== name); section.classList.toggle('is-hidden', key !== name);
}); });
intervalControl.classList.toggle('is-hidden', name === 'overview' || name === 'analysis'); const showInterval = name !== 'recent' && name !== 'analysis';
domainControl.classList.toggle('is-hidden', name !== 'domain'); const showDomain = showInterval;
if (name === 'overview') { intervalControl.classList.toggle('is-hidden', !showInterval);
domainControl.classList.toggle('is-hidden', !showDomain);
windowControl.classList.toggle('is-hidden', !showInterval);
modePercentControl.classList.toggle('is-hidden', !showInterval);
modeGroupControl.classList.toggle('is-hidden', !showInterval);
excludeUncachedControl.classList.toggle('is-hidden', !showInterval);
smoothControl.classList.toggle('is-hidden', !showInterval);
updateURL();
if (name === 'recent') {
loadStats(); loadStats();
} }
if (name === 'analysis') { if (name === 'analysis') {
@ -328,23 +517,72 @@
intervalSelect.addEventListener('change', () => { intervalSelect.addEventListener('change', () => {
currentInterval = intervalSelect.value; currentInterval = intervalSelect.value;
abortLoad(currentLoad); abortLoad(currentLoad);
reset(containers.all); Object.values(containers).forEach(reset);
reset(containers.domain); updateURL();
loadReports(); loadReports();
}); });
domainSelect.addEventListener('change', () => { domainSelect.addEventListener('change', () => {
currentDomain = domainSelect.value; currentDomain = domainSelect.value;
abortLoad(currentLoad); abortLoad(currentLoad);
reset(containers.domain); Object.values(containers).forEach(reset);
updateURL();
loadReports();
});
windowSelect.addEventListener('change', () => {
currentWindow = windowSelect.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(); loadReports();
}); });
tabs.forEach(tab => { tabs.forEach(tab => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab)); tab.addEventListener('click', () => {
switchTab(tab.dataset.tab);
});
}); });
// Initialize state (URL -> localStorage -> defaults)
switchTab('overview'); loadSavedState();
applyURLParams();
// Sync controls
intervalSelect.value = currentInterval;
domainSelect.value = currentDomain;
windowSelect.value = currentWindow;
percentToggle.checked = modePercent;
groupToggle.checked = modeGroup;
excludeUncachedToggle.checked = excludeUncached;
smoothToggle.checked = smoothError;
// Show/hide controls based on active tab
switchTab(currentTab);
</script> </script>
</body> </body>
</html> </html>