feat: add cookies support and improve autorefresh UX

This commit also includes changing of load logic to update row by row
instead of full table toggle. It also changes behaviour on openned
dropdowns blocking refresh
This commit is contained in:
2025-07-01 12:16:15 +03:00
parent bd770aac2f
commit 9098f425f9
6 changed files with 191 additions and 118 deletions

View File

@ -99,6 +99,8 @@
<table id="packages" <table id="packages"
data-classes="table table-hover" data-classes="table table-hover"
data-cookie="true"
data-cookie-id-table="ahriman-packages"
data-export-options='{"fileName": "packages"}' data-export-options='{"fileName": "packages"}'
data-filter-control="true" data-filter-control="true"
data-filter-control-visible="false" data-filter-control-visible="false"
@ -117,8 +119,8 @@
data-sortable="true" data-sortable="true"
data-sort-name="base" data-sort-name="base"
data-sort-order="asc" data-sort-order="asc"
data-toggle="table" data-toolbar="#toolbar"
data-toolbar="#toolbar"> data-unique-id="id">
<thead class="table-primary"> <thead class="table-primary">
<tr> <tr>
<th data-checkbox="true"></th> <th data-checkbox="true"></th>

View File

@ -80,8 +80,7 @@
data-classes="table table-hover" data-classes="table table-hover"
data-sortable="true" data-sortable="true"
data-sort-name="timestamp" data-sort-name="timestamp"
data-sort-order="desc" data-sort-order="desc">
data-toggle="table">
<thead class="table-primary"> <thead class="table-primary">
<tr> <tr>
<th data-align="right" data-field="timestamp">date</th> <th data-align="right" data-field="timestamp">date</th>
@ -509,6 +508,8 @@
} }
ready(_ => { ready(_ => {
packageInfoEventsTable.bootstrapTable({});
packageInfoEventsUpdateChart = new Chart(packageInfoEventsUpdateChartCanvas, { packageInfoEventsUpdateChart = new Chart(packageInfoEventsUpdateChartCanvas, {
type: "line", type: "line",
data: {}, data: {},
@ -539,5 +540,7 @@
clearInterval(packageInfoAutoReloadTask); clearInterval(packageInfoAutoReloadTask);
packageInfoAutoReloadTask = null; // not really required (?) but lets clear everything packageInfoAutoReloadTask = null; // not really required (?) but lets clear everything
}); });
restoreAutoReloadSettings(packageInfoAutoReloadButton, packageInfoAutoReloadInput);
}); });
</script> </script>

View File

@ -113,24 +113,24 @@
convert: response => response.json(), convert: response => response.json(),
}, },
data => { data => {
const payload = data.map(description => { const payload = data
const package_base = description.package.base; .map(description => {
const web_url = description.package.remote.web_url; const package_base = description.package.base;
return { const web_url = description.package.remote.web_url;
id: package_base, return {
base: web_url ? safeLink(web_url, package_base, package_base).outerHTML : safe(package_base), id: package_base,
version: safe(description.package.version), base: web_url ? safeLink(web_url, package_base, package_base).outerHTML : safe(package_base),
packager: description.package.packager ? safe(description.package.packager) : "", version: safe(description.package.version),
packages: listToTable(Object.keys(description.package.packages)), packager: description.package.packager ? safe(description.package.packager) : "",
groups: listToTable(extractListProperties(description.package, "groups")), packages: listToTable(Object.keys(description.package.packages)),
licenses: listToTable(extractListProperties(description.package, "licenses")), groups: listToTable(extractListProperties(description.package, "groups")),
timestamp: new Date(1000 * description.status.timestamp).toISOStringShort(), licenses: listToTable(extractListProperties(description.package, "licenses")),
status: description.status.status, timestamp: new Date(1000 * description.status.timestamp).toISOStringShort(),
}; status: description.status.status,
}); };
});
table.bootstrapTable("load", payload); updateTable(table, payload);
table.bootstrapTable("uncheckAll");
table.bootstrapTable("hideLoading"); table.bootstrapTable("hideLoading");
}, },
error => { error => {
@ -241,15 +241,20 @@
function toggleTableAutoReload(interval) { function toggleTableAutoReload(interval) {
clearInterval(tableAutoReloadTask); clearInterval(tableAutoReloadTask);
tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => { tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => {
if ((getSelection().length === 0) && if (!dashboardModal.classList.contains("show") &&
(table.bootstrapTable("getOptions").pageNumber === 1) && !hasActiveDropdown()) {
(!dashboardModal.classList.contains("show"))) {
reload(true); reload(true);
} }
}); });
} }
ready(_ => { ready(_ => {
const onCheckFunction = function () {
if (packageRemoveButton) {
packageRemoveButton.disabled = !getSelection().length;
}
};
document.querySelectorAll("#repositories a").forEach(element => { document.querySelectorAll("#repositories a").forEach(element => {
element.onclick = _ => { element.onclick = _ => {
repository = { repository = {
@ -264,49 +269,52 @@
}; };
}); });
table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table", _ => { table.bootstrapTable({
if (packageRemoveButton) { onCheck: onCheckFunction,
packageRemoveButton.disabled = !table.bootstrapTable("getSelections").length; onCheckAll: onCheckFunction,
} onClickRow: (data, row, cell) => {
}); if (0 === cell || "base" === cell) {
table.on("click-row.bs.table", (self, data, row, cell) => { const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript
if (0 === cell || "base" === cell) { table.bootstrapTable(method, {field: "id", values: [data.id]});
const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript } else showPackageInfo(data.id);
table.bootstrapTable(method, {field: "id", values: [data.id]}); },
} else showPackageInfo(data.id); onCreatedControls: _ => {
}); new easepick.create({
table.on("created-controls.bs.table", _ => { element: document.querySelector(".bootstrap-table-filter-control-timestamp"),
new easepick.create({ css: [
element: document.querySelector(".bootstrap-table-filter-control-timestamp"), "https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
css: [ ],
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css", grid: 2,
], calendars: 2,
grid: 2, autoApply: false,
calendars: 2, locale: {
autoApply: false, cancel: "Clear",
locale: { },
cancel: "Clear", RangePlugin: {
}, tooltip: false,
RangePlugin: { },
tooltip: false, plugins: [
}, "RangePlugin",
plugins: [ ],
"RangePlugin", setup: picker => {
], picker.on("select", _ => { table.bootstrapTable("triggerSearch"); });
setup: picker => { // replace "Cancel" behaviour to "Clear"
picker.on("select", _ => { table.bootstrapTable("triggerSearch"); }); picker.onClickCancelButton = element => {
// replace "Cancel" behaviour to "Clear" if (picker.isCancelButton(element)) {
picker.onClickCancelButton = element => { picker.clear();
if (picker.isCancelButton(element)) { picker.hide();
picker.clear(); table.bootstrapTable("triggerSearch");
picker.hide(); }
table.bootstrapTable("triggerSearch"); };
} },
}; });
}, },
}); onUncheck: onCheckFunction,
onUncheckAll: onCheckFunction,
}); });
restoreAutoReloadSettings(tableAutoReloadButton, tableAutoReloadInput);
selectRepository(); selectRepository();
{% if autorefresh_intervals %} {% if autorefresh_intervals %}
toggleTableAutoReload(); toggleTableAutoReload();

View File

@ -53,8 +53,7 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
data-show-search-clear-button="true" data-show-search-clear-button="true"
data-sortable="true" data-sortable="true"
data-sort-name="base" data-sort-name="base"
data-sort-order="asc" data-sort-order="asc">
data-toggle="table">
<thead class="table-primary"> <thead class="table-primary">
<tr> <tr>
<th data-sortable="true" data-switchable="false" data-field="name" data-filter-control="input" data-filter-control-placeholder="(any package)">package</th> <th data-sortable="true" data-switchable="false" data-field="name" data-filter-control="input" data-filter-control-placeholder="(any package)">package</th>
@ -128,36 +127,38 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
} }
ready(_ => { ready(_ => {
table.on("created-controls.bs.table", _ => { table.bootstrapTable({
new easepick.create({ onCreatedControls: _ => {
element: document.querySelector(".bootstrap-table-filter-control-timestamp"), new easepick.create({
css: [ element: document.querySelector(".bootstrap-table-filter-control-timestamp"),
"https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css", css: [
], "https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css",
grid: 2, ],
calendars: 2, grid: 2,
autoApply: false, calendars: 2,
locale: { autoApply: false,
cancel: "Clear", locale: {
}, cancel: "Clear",
RangePlugin: { },
tooltip: false, RangePlugin: {
}, tooltip: false,
plugins: [ },
"RangePlugin", plugins: [
], "RangePlugin",
setup: picker => { ],
picker.on("select", _ => { table.bootstrapTable("triggerSearch"); }); setup: picker => {
// replace "Cancel" behaviour to "Clear" picker.on("select", _ => { table.bootstrapTable("triggerSearch"); });
picker.onClickCancelButton = element => { // replace "Cancel" behaviour to "Clear"
if (picker.isCancelButton(element)) { picker.onClickCancelButton = element => {
picker.clear(); if (picker.isCancelButton(element)) {
picker.hide(); picker.clear();
table.bootstrapTable("triggerSearch"); picker.hide();
} table.bootstrapTable("triggerSearch");
}; }
}, };
}); },
});
},
}); });
}); });
</script> </script>

View File

@ -1,23 +1,26 @@
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/js-md5@0.8.3/src/md5.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/js-md5@0.8.3/src/md5.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/tableexport.jquery.plugin@1.30.0/tableExport.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/tableexport.jquery.plugin@1.33.0/tableExport.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/bootstrap-table.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/bootstrap-table.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/export/bootstrap-table-export.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/export/bootstrap-table-export.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/resizable/bootstrap-table-resizable.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/resizable/bootstrap-table-resizable.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/filter-control/bootstrap-table-filter-control.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/filter-control/bootstrap-table-filter-control.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/cookie/bootstrap-table-cookie.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.umd.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.umd.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/highlight.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/highlight.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js" crossorigin="anonymous" type="application/javascript"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js" crossorigin="anonymous" type="application/javascript"></script>
<script> <script>
async function copyToClipboard(text, button) { async function copyToClipboard(text, button) {
@ -62,6 +65,11 @@
return !document.getSelection().isCollapsed; // not sure if it is a valid way, but I guess so return !document.getSelection().isCollapsed; // not sure if it is a valid way, but I guess so
} }
function hasActiveDropdown() {
return Array.from(document.querySelectorAll(".dropdown-menu"))
.some(el => el.classList.contains("show"));
}
function headerClass(status) { function headerClass(status) {
if (status === "pending") return ["bg-warning"]; if (status === "pending") return ["bg-warning"];
if (status === "building") return ["bg-warning"]; if (status === "building") return ["bg-warning"];
@ -110,6 +118,12 @@
.catch(error => onFailure && onFailure(error)); .catch(error => onFailure && onFailure(error));
} }
function readOptionalCookies(key, callback) {
for (let value = Cookies.get(key); !!value; value = undefined) {
callback(value);
}
}
function ready(fn) { function ready(fn) {
if (document.readyState === "complete" || document.readyState === "interactive") { if (document.readyState === "complete" || document.readyState === "interactive") {
setTimeout(fn, 1); setTimeout(fn, 1);
@ -118,6 +132,11 @@
} }
} }
function restoreAutoReloadSettings(toggle, intervalSelector) {
readOptionalCookies(`ahriman-${toggle.id}-refresh-enabled`, value => toggle.checked = value === "true");
readOptionalCookies(`ahriman-${toggle.id}-refresh-interval`, value => toggleActiveElement(intervalSelector, "interval", value));
}
function safe(string) { function safe(string) {
return String(string) return String(string)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
@ -137,6 +156,18 @@
return element; return element;
} }
function toggleActiveElement(selector, dataType, value) {
const targetElement = selector.querySelector(`a[data-${dataType}="${value}"]`);
if (targetElement?.classList?.contains("active")) {
return; // element is already active, skip processing
}
Array.from(selector.children).forEach(il => {
Array.from(il.children).forEach(el => el.classList.remove("active"));
});
targetElement?.classList?.add("active");
}
function toggleAutoReload(toggle, interval, intervalSelector, callback) { function toggleAutoReload(toggle, interval, intervalSelector, callback) {
if (interval) { if (interval) {
toggle.checked = true; // toggle reload toggle.checked = true; // toggle reload
@ -144,24 +175,52 @@
interval = intervalSelector.querySelector(".active")?.dataset?.interval; // find active element interval = intervalSelector.querySelector(".active")?.dataset?.interval; // find active element
} }
let intervalId = null;
if (interval) { if (interval) {
if (toggle.checked) { if (toggle.checked) {
// refresh UI // refresh UI
Array.from(intervalSelector.children).forEach(il => { toggleActiveElement(intervalSelector, "interval", interval);
Array.from(il.children).forEach(el => el.classList.remove("active"));
});
intervalSelector.querySelector(`a[data-interval="${interval}"]`)?.classList?.add("active");
// finally create timer task // finally create timer task
return setInterval(callback, interval); intervalId = setInterval(callback, interval);
} }
} else { } else {
toggle.checked = false; // no active interval found, disable toggle toggle.checked = false; // no active interval found, disable toggle
} }
return null; // return null to assign to keep method sane Cookies.set(`ahriman-${toggle.id}-refresh-enabled`, toggle.checked);
Cookies.set(`ahriman-${toggle.id}-refresh-interval`, interval);
return intervalId;
} }
Date.prototype.toISOStringShort = function() { function updateTable(table, rows) {
// instead of using load method here, we just update rows manually to avoid table reinitialization
const currentData = table.bootstrapTable("getData").reduce((accumulator, row) => {
accumulator[row.id] = row["0"];
return accumulator;
}, {});
// insert or update rows
rows.forEach(row => {
if (Object.hasOwn(currentData, row.id)) {
row["0"] = currentData[row.id]; // copy checkbox state
table.bootstrapTable("updateByUniqueId", {
id: row.id,
row: row,
replace: true,
});
} else {
table.bootstrapTable("insertRow", {index: 0, row: row});
}
});
// remove old rows
const newData = rows.map(value => value.id);
Object.keys(currentData).forEach(id => {
if (!newData.includes(id)) {
table.bootstrapTable("removeByUniqueId", id);
}
});
}
Date.prototype.toISOStringShort = function () {
const pad = number => String(number).padStart(2, "0"); const pad = number => String(number).padStart(2, "0");
return `${this.getFullYear()}-${pad(this.getMonth() + 1)}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`; return `${this.getFullYear()}-${pad(this.getMonth() + 1)}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`;
} }

View File

@ -1,15 +1,15 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/bootstrap-table.min.css" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/bootstrap-table.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jquery-resizable-columns@0.2.3/dist/jquery.resizableColumns.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.23.2/dist/extensions/filter-control/bootstrap-table-filter-control.css" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-table@1.24.1/dist/extensions/filter-control/bootstrap-table-filter-control.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.3/dist/cosmo/bootstrap.min.css" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootswatch@5.3.7/dist/cosmo/bootstrap.min.css" crossorigin="anonymous" type="text/css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.10.0/build/styles/github.min.css" crossorigin="anonymous" type="text/css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.11.1/build/styles/github.min.css" crossorigin="anonymous" type="text/css">
<style> <style>
.pre-scrollable { .pre-scrollable {