Files
coolify/resources/js/traffic-table.js
T

129 lines
4.5 KiB
JavaScript

// Client-side sort and paging for the traffic analytics lists. Each row carries
// its sort values as JSON in `data-row`. Rows are moved in the DOM, so keyboard
// and screen-reader order match the visual order. A Livewire morph puts the rows
// back in server order (they are keyed) and can add or remove rows, so the rows
// are sorted again after it. Rows marked `data-hidable` (container tooling user
// agents) are left out of the sort and the pages until `showHidden` is on.
function compareValues(a, b) {
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
}
function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) {
const ranks = new WeakMap();
let observer = null;
let removeMorphHook = null;
return {
page: 0,
per,
total: 0,
key,
dir,
tick: 0,
showHidden: false,
hiddenCount: 0,
init() {
this.resort();
observer = new MutationObserver(() => this.resort());
observer.observe(this.$root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-row'],
});
if (window.Livewire) {
removeMorphHook = window.Livewire.hook('morphed', ({ el }) => {
if (el.contains(this.$root)) {
this.resort();
}
});
}
},
destroy() {
if (observer) {
observer.disconnect();
observer = null;
}
if (removeMorphHook) {
removeMorphHook();
removeMorphHook = null;
}
},
// Text columns start ascending, number columns descending.
sortBy(next, firstDir = 'desc') {
if (this.key === next) {
this.dir = this.dir === 'desc' ? 'asc' : 'desc';
} else {
this.key = next;
this.dir = firstDir;
}
this.page = 0;
this.resort();
},
toggleHidden() {
this.showHidden = !this.showHidden;
this.page = 0;
this.resort();
},
resort() {
const sign = this.dir === 'asc' ? 1 : -1;
const all = [...this.$root.querySelectorAll('[data-row]')];
const hidable = all.filter((el) => 'hidable' in el.dataset);
hidable.forEach((el) => ranks.delete(el));
this.hiddenCount = hidable.length;
const rows = all.filter((el) => this.showHidden || !('hidable' in el.dataset)).map((el, index) => {
let values = {};
try {
values = JSON.parse(el.dataset.row) || {};
} catch {
values = {};
}
const value = values[this.key] ?? null;
return { el, index, pinned: 'pinned' in el.dataset ? 1 : 0, missing: value === null ? 1 : 0, value };
});
// Pinned rows (the "Other" remainder) stay last in each sort, then rows without a value.
rows.sort((a, b) => a.pinned - b.pinned || a.missing - b.missing || compareValues(a.value, b.value) * sign || a.index - b.index);
rows.forEach((row, rank) => ranks.set(row.el, rank));
// All rows share one parent. Move them only when the order changed: the mutation
// that the move causes then finds them sorted, and a focused link is not blurred.
if (rows.some((row, rank) => row.index !== rank)) {
rows.forEach((row) => row.el.parentElement.appendChild(row.el));
observer?.takeRecords();
}
this.total = rows.length;
this.page = Math.min(this.page, Math.max(0, Math.ceil(this.total / this.per) - 1));
this.tick++;
},
shown(el) {
this.tick;
const rank = ranks.get(el);
if (rank === undefined) {
return false;
}
return rank >= this.page * this.per && rank < (this.page + 1) * this.per;
},
// 'asc', 'desc', or null when the list is sorted by another column.
sorted(column) {
return this.key === column ? this.dir : null;
},
};
}
window.trafficTable = trafficTable;