Add chart manager for abortable fetches #49

Merged
wagesj45 merged 1 commit from codex/implement-chartmanager-for-loading-and-aborting into main 2025-07-19 18:01:37 -05:00
3 changed files with 97 additions and 38 deletions
Showing only changes of commit 5d2546ad60 - Show all commits

Add chart loading management

Jordan Wages 2025-07-19 18:01:26 -05:00

View file

@ -58,15 +58,18 @@ def _save_json(path: Path, data: List[Dict]) -> None:
def _copy_icons() -> None:
"""Copy vendored icons to the output directory."""
"""Copy vendored icons and scripts to the output directory."""
src_dir = Path("static/icons")
dst_dir = OUTPUT_DIR / "icons"
if not src_dir.is_dir():
return
if src_dir.is_dir():
dst_dir.mkdir(parents=True, exist_ok=True)
for icon in src_dir.glob("*.svg"):
shutil.copy(icon, dst_dir / icon.name)
js_src = Path("static/chartManager.js")
if js_src.is_file():
shutil.copy(js_src, OUTPUT_DIR / js_src.name)
def _render_snippet(report: Dict, out_dir: Path) -> None:
"""Render a single report snippet to ``<name>.html`` inside ``out_dir``."""

49
static/chartManager.js Normal file
View file

@ -0,0 +1,49 @@
export let currentLoad = null;
const loadInfo = new Map();
export function newLoad(container) {
if (currentLoad) {
abortLoad(currentLoad);
}
reset(container);
const controller = new AbortController();
const token = { controller, charts: new Map() };
loadInfo.set(token, token);
currentLoad = token;
return token;
}
export function abortLoad(token) {
const info = loadInfo.get(token);
if (!info) return;
info.controller.abort();
info.charts.forEach(chart => {
try {
chart.destroy();
} catch (e) {}
});
loadInfo.delete(token);
if (currentLoad === token) {
currentLoad = null;
}
}
export function registerChart(token, id, chart) {
const info = loadInfo.get(token);
if (info) {
info.charts.set(id, chart);
} else {
chart.destroy();
}
}
export function reset(container) {
if (!container) return;
container.querySelectorAll('canvas').forEach(c => {
const chart = Chart.getChart(c);
if (chart) {
chart.destroy();
}
});
container.innerHTML = '';
}

View file

@ -72,7 +72,14 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.0/dist/jquery.min.js"></script>
<script src="https://cdn.datatables.net/1.13.4/js/jquery.dataTables.min.js"></script>
<script>
<script type="module">
import {
newLoad,
abortLoad,
registerChart,
reset,
currentLoad,
} from './chartManager.js';
const intervalSelect = document.getElementById('interval-select');
const domainSelect = document.getElementById('domain-select');
const intervalControl = document.getElementById('interval-control');
@ -105,20 +112,22 @@
let currentDomain = domainSelect.value;
let currentTab = 'overview';
function initReport(rep, base) {
fetch(base + '/' + rep.json)
function initReport(token, rep, base) {
fetch(base + '/' + rep.json, { signal: token.controller.signal })
.then(r => r.json())
.then(data => {
if (token !== currentLoad) return;
const bucketField = rep.bucket || 'bucket';
if (rep.chart === 'table') {
const rows = data.map(x => [x[bucketField], x.value]);
new DataTable('#table-' + rep.name, {
const table = new DataTable('#table-' + rep.name, {
data: rows,
columns: [
{ title: rep.bucket_label || 'Bucket' },
{ title: 'Value' }
]
});
registerChart(token, rep.name, table);
return;
}
@ -146,7 +155,7 @@
dataset.backgroundColor = 'rgba(54, 162, 235, 0.5)';
dataset.borderColor = 'rgba(54, 162, 235, 1)';
}
new Chart(document.getElementById('chart-' + rep.name), {
const chart = new Chart(document.getElementById('chart-' + rep.name), {
type: chartType,
data: {
labels: labels,
@ -154,6 +163,7 @@
},
options: options
});
registerChart(token, rep.name, chart);
});
}
@ -171,18 +181,7 @@
});
}
function destroyCharts(container) {
container.querySelectorAll('canvas').forEach(c => {
const chart = Chart.getChart(c);
if (chart) {
chart.destroy();
}
});
}
function destroyAllCharts() {
Object.values(containers).forEach(destroyCharts);
}
// Reset helpers managed by chartManager
function loadReports() {
let path;
@ -196,24 +195,26 @@
} else {
container = containers.domain;
if (!currentDomain) {
destroyCharts(container);
reset(container);
container.innerHTML = '<p>Select a domain</p>';
return;
}
path = 'domains/' + encodeURIComponent(currentDomain) + '/' + currentInterval;
}
fetch(path + '/reports.json')
const token = newLoad(container);
fetch(path + '/reports.json', { signal: token.controller.signal })
.then(r => r.json())
.then(reports => {
destroyCharts(container);
container.innerHTML = '';
if (token !== currentLoad) return;
reports.forEach(rep => {
fetch(path + '/' + rep.html)
fetch(path + '/' + rep.html, { signal: token.controller.signal })
.then(r => r.text())
.then(html => {
if (token !== currentLoad) return;
container.insertAdjacentHTML('beforeend', html);
initReport(rep, path);
initReport(token, rep, path);
});
});
});
@ -300,7 +301,8 @@
}
function switchTab(name) {
destroyAllCharts();
abortLoad(currentLoad);
Object.values(containers).forEach(reset);
currentTab = name;
tabs.forEach(tab => {
tab.classList.toggle('is-active', tab.dataset.tab === name);
@ -322,11 +324,16 @@
intervalSelect.addEventListener('change', () => {
currentInterval = intervalSelect.value;
abortLoad(currentLoad);
reset(containers.all);
reset(containers.domain);
loadReports();
});
domainSelect.addEventListener('change', () => {
currentDomain = domainSelect.value;
abortLoad(currentLoad);
reset(containers.domain);
loadReports();
});