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:
parent
9c26ae3e90
commit
fab91d2e04
4 changed files with 442 additions and 59 deletions
41
reports.yml
41
reports.yml
|
@ -48,6 +48,7 @@
|
|||
label: Top Domains
|
||||
icon: globe
|
||||
chart: table
|
||||
top_n: 50
|
||||
per_domain: false
|
||||
bucket: domain
|
||||
bucket_label: Domain
|
||||
|
@ -75,6 +76,7 @@
|
|||
label: Top Paths
|
||||
icon: map
|
||||
chart: table
|
||||
top_n: 50
|
||||
buckets:
|
||||
- domain
|
||||
- path
|
||||
|
@ -102,6 +104,7 @@
|
|||
label: User Agents
|
||||
icon: user
|
||||
chart: table
|
||||
top_n: 50
|
||||
buckets:
|
||||
- domain
|
||||
- user_agent
|
||||
|
@ -127,6 +130,7 @@
|
|||
label: Referrers
|
||||
icon: link
|
||||
chart: table
|
||||
top_n: 50
|
||||
buckets:
|
||||
- domain
|
||||
- referrer
|
||||
|
@ -170,3 +174,40 @@
|
|||
- "#209cee"
|
||||
- "#ffdd57"
|
||||
- "#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
|
||||
|
|
|
@ -178,6 +178,16 @@ def _generate_interval(interval: str, domain: Optional[str] = None) -> None:
|
|||
name = definition["name"]
|
||||
query = definition["query"].replace("{bucket}", bucket)
|
||||
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)
|
||||
rows = cur.fetchall()
|
||||
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"]
|
||||
if "colors" in definition:
|
||||
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)
|
||||
report_list.append(entry)
|
||||
|
||||
|
@ -266,6 +288,16 @@ def _generate_global() -> None:
|
|||
|
||||
name = definition["name"]
|
||||
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)
|
||||
rows = cur.fetchall()
|
||||
headers = [c[0] for c in cur.description]
|
||||
|
@ -291,6 +323,18 @@ def _generate_global() -> None:
|
|||
entry["color"] = definition["color"]
|
||||
if "colors" in definition:
|
||||
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)
|
||||
report_list.append(entry)
|
||||
|
||||
|
|
|
@ -47,3 +47,63 @@ export function reset(container) {
|
|||
});
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -12,14 +12,15 @@
|
|||
|
||||
<div class="tabs is-toggle" id="report-tabs">
|
||||
<ul>
|
||||
<li class="is-active" data-tab="overview"><a>Overview</a></li>
|
||||
<li data-tab="all"><a>All Domains</a></li>
|
||||
<li data-tab="domain"><a>Per Domain</a></li>
|
||||
<li class="is-active" data-tab="recent"><a>Recent</a></li>
|
||||
<li data-tab="trends"><a>Trends</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>
|
||||
</ul>
|
||||
</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 class="select is-small">
|
||||
<select id="interval-select">
|
||||
|
@ -41,11 +42,43 @@
|
|||
</div>
|
||||
<span class="icon is-small is-left"><img src="icons/server.svg" alt="Domain"></span>
|
||||
</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 id="overview-section">
|
||||
<div id="recent-section">
|
||||
<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>Date range: <span id="stat-start">-</span> to <span id="stat-end">-</span></p>
|
||||
<p>Unique domains: <span id="stat-domains">-</span></p>
|
||||
|
@ -55,12 +88,16 @@
|
|||
<div id="overview-reports"></div>
|
||||
</div>
|
||||
|
||||
<div id="all-section" class="is-hidden">
|
||||
<div id="reports-all"></div>
|
||||
<div id="trends-section" class="is-hidden">
|
||||
<div id="reports-trends"></div>
|
||||
</div>
|
||||
|
||||
<div id="domain-section" class="is-hidden">
|
||||
<div id="reports-domain"></div>
|
||||
<div id="distribution-section" class="is-hidden">
|
||||
<div id="reports-distribution"></div>
|
||||
</div>
|
||||
|
||||
<div id="tables-section" class="is-hidden">
|
||||
<div id="reports-tables"></div>
|
||||
</div>
|
||||
|
||||
<div id="analysis-section" class="is-hidden">
|
||||
|
@ -79,22 +116,34 @@
|
|||
registerChart,
|
||||
reset,
|
||||
currentLoad,
|
||||
sliceWindow,
|
||||
excludeValues,
|
||||
toPercent,
|
||||
groupOthers,
|
||||
movingAverage,
|
||||
} from './chartManager.js';
|
||||
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 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 sections = {
|
||||
overview: document.getElementById('overview-section'),
|
||||
all: document.getElementById('all-section'),
|
||||
domain: document.getElementById('domain-section'),
|
||||
recent: document.getElementById('recent-section'),
|
||||
trends: document.getElementById('trends-section'),
|
||||
distribution: document.getElementById('distribution-section'),
|
||||
tables: document.getElementById('tables-section'),
|
||||
analysis: document.getElementById('analysis-section')
|
||||
};
|
||||
const containers = {
|
||||
overview: document.getElementById('overview-reports'),
|
||||
all: document.getElementById('reports-all'),
|
||||
domain: document.getElementById('reports-domain')
|
||||
recent: document.getElementById('overview-reports'),
|
||||
trends: document.getElementById('reports-trends'),
|
||||
distribution: document.getElementById('reports-distribution'),
|
||||
tables: document.getElementById('reports-tables')
|
||||
};
|
||||
const analysisElems = {
|
||||
missing: document.getElementById('analysis-missing'),
|
||||
|
@ -108,9 +157,87 @@
|
|||
const generatedElem = document.getElementById('stat-generated');
|
||||
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 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) {
|
||||
fetch(base + '/' + rep.json, { signal: token.controller.signal })
|
||||
|
@ -133,15 +260,64 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Transform pipeline (client-only)
|
||||
let transformed = data.slice();
|
||||
const bucketField = bucketFields[0];
|
||||
const labelsArr = data.map(x => x[bucketField]);
|
||||
const values = data.map(x => x.value);
|
||||
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) {
|
||||
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 || [
|
||||
'#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,
|
||||
|
@ -158,11 +334,18 @@
|
|||
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: [dataset]
|
||||
datasets
|
||||
},
|
||||
options: options
|
||||
});
|
||||
|
@ -188,21 +371,11 @@
|
|||
|
||||
function loadReports() {
|
||||
let path;
|
||||
let container;
|
||||
if (currentTab === 'overview') {
|
||||
let container = containers[currentTab];
|
||||
if (currentTab === 'recent') {
|
||||
path = 'global';
|
||||
container = containers.overview;
|
||||
} else if (currentTab === 'all') {
|
||||
path = currentInterval;
|
||||
container = containers.all;
|
||||
} else {
|
||||
container = containers.domain;
|
||||
if (!currentDomain) {
|
||||
reset(container);
|
||||
container.innerHTML = '<p>Select a domain</p>';
|
||||
return;
|
||||
}
|
||||
path = 'domains/' + encodeURIComponent(currentDomain) + '/' + currentInterval;
|
||||
path = currentDomain ? ('domains/' + encodeURIComponent(currentDomain) + '/' + currentInterval) : currentInterval;
|
||||
}
|
||||
|
||||
const token = newLoad(container);
|
||||
|
@ -211,7 +384,15 @@
|
|||
.then(r => r.json())
|
||||
.then(reports => {
|
||||
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 })
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
|
@ -313,9 +494,17 @@
|
|||
Object.entries(sections).forEach(([key, section]) => {
|
||||
section.classList.toggle('is-hidden', key !== name);
|
||||
});
|
||||
intervalControl.classList.toggle('is-hidden', name === 'overview' || name === 'analysis');
|
||||
domainControl.classList.toggle('is-hidden', name !== 'domain');
|
||||
if (name === 'overview') {
|
||||
const showInterval = name !== 'recent' && name !== 'analysis';
|
||||
const showDomain = showInterval;
|
||||
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();
|
||||
}
|
||||
if (name === 'analysis') {
|
||||
|
@ -328,23 +517,72 @@
|
|||
intervalSelect.addEventListener('change', () => {
|
||||
currentInterval = intervalSelect.value;
|
||||
abortLoad(currentLoad);
|
||||
reset(containers.all);
|
||||
reset(containers.domain);
|
||||
Object.values(containers).forEach(reset);
|
||||
updateURL();
|
||||
loadReports();
|
||||
});
|
||||
|
||||
domainSelect.addEventListener('change', () => {
|
||||
currentDomain = domainSelect.value;
|
||||
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();
|
||||
});
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
|
||||
tab.addEventListener('click', () => {
|
||||
switchTab(tab.dataset.tab);
|
||||
});
|
||||
|
||||
switchTab('overview');
|
||||
});
|
||||
// Initialize state (URL -> localStorage -> defaults)
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue