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
|
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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue