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
|
@ -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,13 +88,17 @@
|
|||
<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>
|
||||
<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">
|
||||
<div id="analysis-missing" class="box"></div>
|
||||
|
@ -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,36 +260,92 @@
|
|||
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;
|
||||
}
|
||||
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.color) {
|
||||
dataset.backgroundColor = rep.color;
|
||||
dataset.borderColor = rep.color;
|
||||
// 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 {
|
||||
dataset.backgroundColor = 'rgba(54, 162, 235, 0.5)';
|
||||
dataset.borderColor = 'rgba(54, 162, 235, 1)';
|
||||
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.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: [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