mirror of
				https://github.com/arcan1s/ahriman.git
				synced 2025-10-31 05:43:41 +00:00 
			
		
		
		
	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:
		| @ -99,6 +99,9 @@ | ||||
|  | ||||
|             <table id="packages" | ||||
|                    data-classes="table table-hover" | ||||
|                    data-cookie="true" | ||||
|                    data-cookie-id-table="ahriman-packages" | ||||
|                    data-cookie-storage="localStorage" | ||||
|                    data-export-options='{"fileName": "packages"}' | ||||
|                    data-filter-control="true" | ||||
|                    data-filter-control-visible="false" | ||||
| @ -117,8 +120,8 @@ | ||||
|                    data-sortable="true" | ||||
|                    data-sort-name="base" | ||||
|                    data-sort-order="asc" | ||||
|                    data-toggle="table" | ||||
|                    data-toolbar="#toolbar"> | ||||
|                    data-toolbar="#toolbar" | ||||
|                    data-unique-id="id"> | ||||
|                 <thead class="table-primary"> | ||||
|                     <tr> | ||||
|                         <th data-checkbox="true"></th> | ||||
|  | ||||
| @ -80,8 +80,7 @@ | ||||
|                                data-classes="table table-hover" | ||||
|                                data-sortable="true" | ||||
|                                data-sort-name="timestamp" | ||||
|                                data-sort-order="desc" | ||||
|                                data-toggle="table"> | ||||
|                                data-sort-order="desc"> | ||||
|                             <thead class="table-primary"> | ||||
|                                 <tr> | ||||
|                                     <th data-align="right" data-field="timestamp">date</th> | ||||
| @ -509,6 +508,8 @@ | ||||
|     } | ||||
|  | ||||
|     ready(_ => { | ||||
|         packageInfoEventsTable.bootstrapTable({}); | ||||
|  | ||||
|         packageInfoEventsUpdateChart = new Chart(packageInfoEventsUpdateChartCanvas, { | ||||
|             type: "line", | ||||
|             data: {}, | ||||
| @ -539,5 +540,7 @@ | ||||
|             clearInterval(packageInfoAutoReloadTask); | ||||
|             packageInfoAutoReloadTask = null; // not really required (?) but lets clear everything | ||||
|         }); | ||||
|  | ||||
|         restoreAutoReloadSettings(packageInfoAutoReloadButton, packageInfoAutoReloadInput); | ||||
|     }); | ||||
| </script> | ||||
|  | ||||
| @ -113,24 +113,24 @@ | ||||
|                 convert: response => response.json(), | ||||
|             }, | ||||
|             data => { | ||||
|                 const payload = data.map(description => { | ||||
|                     const package_base = description.package.base; | ||||
|                     const web_url = description.package.remote.web_url; | ||||
|                     return { | ||||
|                         id: package_base, | ||||
|                         base: web_url ? safeLink(web_url, package_base, package_base).outerHTML : safe(package_base), | ||||
|                         version: safe(description.package.version), | ||||
|                         packager: description.package.packager ? safe(description.package.packager) : "", | ||||
|                         packages: listToTable(Object.keys(description.package.packages)), | ||||
|                         groups: listToTable(extractListProperties(description.package, "groups")), | ||||
|                         licenses: listToTable(extractListProperties(description.package, "licenses")), | ||||
|                         timestamp: new Date(1000 * description.status.timestamp).toISOStringShort(), | ||||
|                         status: description.status.status, | ||||
|                     }; | ||||
|                 }); | ||||
|                 const payload = data | ||||
|                     .map(description => { | ||||
|                         const package_base = description.package.base; | ||||
|                         const web_url = description.package.remote.web_url; | ||||
|                         return { | ||||
|                             id: package_base, | ||||
|                             base: web_url ? safeLink(web_url, package_base, package_base).outerHTML : safe(package_base), | ||||
|                             version: safe(description.package.version), | ||||
|                             packager: description.package.packager ? safe(description.package.packager) : "", | ||||
|                             packages: listToTable(Object.keys(description.package.packages)), | ||||
|                             groups: listToTable(extractListProperties(description.package, "groups")), | ||||
|                             licenses: listToTable(extractListProperties(description.package, "licenses")), | ||||
|                             timestamp: new Date(1000 * description.status.timestamp).toISOStringShort(), | ||||
|                             status: description.status.status, | ||||
|                         }; | ||||
|                     }); | ||||
|  | ||||
|                 table.bootstrapTable("load", payload); | ||||
|                 table.bootstrapTable("uncheckAll"); | ||||
|                 updateTable(table, payload); | ||||
|                 table.bootstrapTable("hideLoading"); | ||||
|             }, | ||||
|             error => { | ||||
| @ -241,15 +241,20 @@ | ||||
|     function toggleTableAutoReload(interval) { | ||||
|         clearInterval(tableAutoReloadTask); | ||||
|         tableAutoReloadTask = toggleAutoReload(tableAutoReloadButton, interval, tableAutoReloadInput, _ => { | ||||
|             if ((getSelection().length === 0) && | ||||
|                 (table.bootstrapTable("getOptions").pageNumber === 1) && | ||||
|                 (!dashboardModal.classList.contains("show"))) { | ||||
|             if (!dashboardModal.classList.contains("show") && | ||||
|                 !hasActiveDropdown()) { | ||||
|                 reload(true); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     ready(_ => { | ||||
|         const onCheckFunction = function () { | ||||
|             if (packageRemoveButton) { | ||||
|                 packageRemoveButton.disabled = !getSelection().length; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         document.querySelectorAll("#repositories a").forEach(element => { | ||||
|             element.onclick = _ => { | ||||
|                 repository = { | ||||
| @ -264,49 +269,52 @@ | ||||
|             }; | ||||
|         }); | ||||
|  | ||||
|         table.on("check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table", _ => { | ||||
|             if (packageRemoveButton) { | ||||
|                 packageRemoveButton.disabled = !table.bootstrapTable("getSelections").length; | ||||
|             } | ||||
|         }); | ||||
|         table.on("click-row.bs.table", (self, data, row, cell) => { | ||||
|             if (0 === cell || "base" === cell) { | ||||
|                 const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript | ||||
|                 table.bootstrapTable(method, {field: "id", values: [data.id]}); | ||||
|             } else showPackageInfo(data.id); | ||||
|         }); | ||||
|         table.on("created-controls.bs.table", _ => { | ||||
|             new easepick.create({ | ||||
|                 element: document.querySelector(".bootstrap-table-filter-control-timestamp"), | ||||
|                 css: [ | ||||
|                     "https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css", | ||||
|                 ], | ||||
|                 grid: 2, | ||||
|                 calendars: 2, | ||||
|                 autoApply: false, | ||||
|                 locale: { | ||||
|                     cancel: "Clear", | ||||
|                 }, | ||||
|                 RangePlugin: { | ||||
|                     tooltip: false, | ||||
|                 }, | ||||
|                 plugins: [ | ||||
|                     "RangePlugin", | ||||
|                 ], | ||||
|                 setup: picker => { | ||||
|                     picker.on("select", _ => { table.bootstrapTable("triggerSearch"); }); | ||||
|                     // replace "Cancel" behaviour to "Clear" | ||||
|                     picker.onClickCancelButton = element => { | ||||
|                         if (picker.isCancelButton(element)) { | ||||
|                             picker.clear(); | ||||
|                             picker.hide(); | ||||
|                             table.bootstrapTable("triggerSearch"); | ||||
|                         } | ||||
|                     }; | ||||
|                 }, | ||||
|             }); | ||||
|         table.bootstrapTable({ | ||||
|             onCheck: onCheckFunction, | ||||
|             onCheckAll: onCheckFunction, | ||||
|             onClickRow: (data, row, cell) => { | ||||
|                 if (0 === cell || "base" === cell) { | ||||
|                     const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript | ||||
|                     table.bootstrapTable(method, {field: "id", values: [data.id]}); | ||||
|                 } else showPackageInfo(data.id); | ||||
|             }, | ||||
|             onCreatedControls: _ => { | ||||
|                 new easepick.create({ | ||||
|                     element: document.querySelector(".bootstrap-table-filter-control-timestamp"), | ||||
|                     css: [ | ||||
|                         "https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css", | ||||
|                     ], | ||||
|                     grid: 2, | ||||
|                     calendars: 2, | ||||
|                     autoApply: false, | ||||
|                     locale: { | ||||
|                         cancel: "Clear", | ||||
|                     }, | ||||
|                     RangePlugin: { | ||||
|                         tooltip: false, | ||||
|                     }, | ||||
|                     plugins: [ | ||||
|                         "RangePlugin", | ||||
|                     ], | ||||
|                     setup: picker => { | ||||
|                         picker.on("select", _ => { table.bootstrapTable("triggerSearch"); }); | ||||
|                         // replace "Cancel" behaviour to "Clear" | ||||
|                         picker.onClickCancelButton = element => { | ||||
|                             if (picker.isCancelButton(element)) { | ||||
|                                 picker.clear(); | ||||
|                                 picker.hide(); | ||||
|                                 table.bootstrapTable("triggerSearch"); | ||||
|                             } | ||||
|                         }; | ||||
|                     }, | ||||
|                 }); | ||||
|             }, | ||||
|             onUncheck: onCheckFunction, | ||||
|             onUncheckAll: onCheckFunction, | ||||
|         }); | ||||
|  | ||||
|         restoreAutoReloadSettings(tableAutoReloadButton, tableAutoReloadInput); | ||||
|  | ||||
|         selectRepository(); | ||||
|         {% if autorefresh_intervals %} | ||||
|             toggleTableAutoReload(); | ||||
|  | ||||
| @ -53,8 +53,7 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa | ||||
|                    data-show-search-clear-button="true" | ||||
|                    data-sortable="true" | ||||
|                    data-sort-name="base" | ||||
|                    data-sort-order="asc" | ||||
|                    data-toggle="table"> | ||||
|                    data-sort-order="asc"> | ||||
|                 <thead class="table-primary"> | ||||
|                     <tr> | ||||
|                         <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(_ => { | ||||
|                 table.on("created-controls.bs.table", _ => { | ||||
|                     new easepick.create({ | ||||
|                         element: document.querySelector(".bootstrap-table-filter-control-timestamp"), | ||||
|                         css: [ | ||||
|                             "https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css", | ||||
|                         ], | ||||
|                         grid: 2, | ||||
|                         calendars: 2, | ||||
|                         autoApply: false, | ||||
|                         locale: { | ||||
|                             cancel: "Clear", | ||||
|                         }, | ||||
|                         RangePlugin: { | ||||
|                             tooltip: false, | ||||
|                         }, | ||||
|                         plugins: [ | ||||
|                             "RangePlugin", | ||||
|                         ], | ||||
|                         setup: picker => { | ||||
|                             picker.on("select", _ => { table.bootstrapTable("triggerSearch"); }); | ||||
|                             // replace "Cancel" behaviour to "Clear" | ||||
|                             picker.onClickCancelButton = element => { | ||||
|                                 if (picker.isCancelButton(element)) { | ||||
|                                     picker.clear(); | ||||
|                                     picker.hide(); | ||||
|                                     table.bootstrapTable("triggerSearch"); | ||||
|                                 } | ||||
|                             }; | ||||
|                         }, | ||||
|                     }); | ||||
|                 table.bootstrapTable({ | ||||
|                     onCreatedControls: _ => { | ||||
|                         new easepick.create({ | ||||
|                             element: document.querySelector(".bootstrap-table-filter-control-timestamp"), | ||||
|                             css: [ | ||||
|                                 "https://cdn.jsdelivr.net/npm/@easepick/bundle@1.2.1/dist/index.css", | ||||
|                             ], | ||||
|                             grid: 2, | ||||
|                             calendars: 2, | ||||
|                             autoApply: false, | ||||
|                             locale: { | ||||
|                                 cancel: "Clear", | ||||
|                             }, | ||||
|                             RangePlugin: { | ||||
|                                 tooltip: false, | ||||
|                             }, | ||||
|                             plugins: [ | ||||
|                                 "RangePlugin", | ||||
|                             ], | ||||
|                             setup: picker => { | ||||
|                                 picker.on("select", _ => { table.bootstrapTable("triggerSearch"); }); | ||||
|                                 // replace "Cancel" behaviour to "Clear" | ||||
|                                 picker.onClickCancelButton = element => { | ||||
|                                     if (picker.isCancelButton(element)) { | ||||
|                                         picker.clear(); | ||||
|                                         picker.hide(); | ||||
|                                         table.bootstrapTable("triggerSearch"); | ||||
|                                     } | ||||
|                                 }; | ||||
|                             }, | ||||
|                         }); | ||||
|                     }, | ||||
|                 }); | ||||
|             }); | ||||
|         </script> | ||||
|  | ||||
| @ -1,23 +1,24 @@ | ||||
| <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/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/@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-table@1.23.2/dist/bootstrap-table.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.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.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.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/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/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/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/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> | ||||
|     async function copyToClipboard(text, button) { | ||||
| @ -62,6 +63,11 @@ | ||||
|         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) { | ||||
|         if (status === "pending") return ["bg-warning"]; | ||||
|         if (status === "building") return ["bg-warning"]; | ||||
| @ -110,6 +116,12 @@ | ||||
|             .catch(error => onFailure && onFailure(error)); | ||||
|     } | ||||
|  | ||||
|     function readOptional(extractor, callback) { | ||||
|         for (let value = extractor(); !!value; value = null) { | ||||
|             callback(value); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function ready(fn) { | ||||
|         if (document.readyState === "complete" || document.readyState === "interactive") { | ||||
|             setTimeout(fn, 1); | ||||
| @ -118,6 +130,11 @@ | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     function restoreAutoReloadSettings(toggle, intervalSelector) { | ||||
|         readOptional(() => localStorage.getItem(`ahriman-${toggle.id}-refresh-enabled`), value => toggle.checked = value === "true"); | ||||
|         readOptional(() => localStorage.getItem(`ahriman-${toggle.id}-refresh-interval`), value => toggleActiveElement(intervalSelector, "interval", value)); | ||||
|     } | ||||
|  | ||||
|     function safe(string) { | ||||
|         return String(string) | ||||
|             .replace(/&/g, "&") | ||||
| @ -137,6 +154,18 @@ | ||||
|         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) { | ||||
|         if (interval) { | ||||
|             toggle.checked = true; // toggle reload | ||||
| @ -144,24 +173,52 @@ | ||||
|             interval = intervalSelector.querySelector(".active")?.dataset?.interval; // find active element | ||||
|         } | ||||
|  | ||||
|         let intervalId = null; | ||||
|         if (interval) { | ||||
|             if (toggle.checked) { | ||||
|                 // refresh UI | ||||
|                 Array.from(intervalSelector.children).forEach(il => { | ||||
|                     Array.from(il.children).forEach(el => el.classList.remove("active")); | ||||
|                 }); | ||||
|                 intervalSelector.querySelector(`a[data-interval="${interval}"]`)?.classList?.add("active"); | ||||
|                 toggleActiveElement(intervalSelector, "interval", interval); | ||||
|                 // finally create timer task | ||||
|                 return setInterval(callback, interval); | ||||
|                 intervalId = setInterval(callback, interval); | ||||
|             } | ||||
|         } else { | ||||
|             toggle.checked = false; // no active interval found, disable toggle | ||||
|         } | ||||
|  | ||||
|         return null; // return null to assign to keep method sane | ||||
|         localStorage.setItem(`ahriman-${toggle.id}-refresh-enabled`, toggle.checked); | ||||
|         localStorage.setItem(`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"); | ||||
|         return `${this.getFullYear()}-${pad(this.getMonth() + 1)}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`; | ||||
|     } | ||||
|  | ||||
| @ -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-icons@1.11.1/font/bootstrap-icons.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.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/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> | ||||
|     .pre-scrollable { | ||||
|  | ||||
		Reference in New Issue
	
	Block a user