Initialer Import des AssetManagers
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
(() => {
|
||||
function init() {
|
||||
const table = document.getElementById('asset-table');
|
||||
if (!table || table.dataset.columnManagerReady === '1') return;
|
||||
table.dataset.columnManagerReady = '1';
|
||||
|
||||
const scroll = document.getElementById('asset-table-scroll');
|
||||
const topScroll = document.getElementById('asset-table-scroll-top');
|
||||
const topInner = topScroll?.firstElementChild;
|
||||
const dialog = document.getElementById('column-settings-dialog');
|
||||
const list = document.getElementById('column-settings-list');
|
||||
const openButton = document.getElementById('column-settings-button');
|
||||
const showAllButton = document.getElementById('show-all-columns');
|
||||
const resetButton = document.getElementById('reset-columns');
|
||||
const menu = document.getElementById('column-context-menu');
|
||||
const hideMenuButton = document.getElementById('context-hide-column');
|
||||
const openMenuButton = document.getElementById('context-open-columns');
|
||||
|
||||
const keyBase = table.dataset.storageKey || 'asset-columns-all';
|
||||
const hiddenKey = `${keyBase}-hidden`;
|
||||
const styleId = `asset-column-visibility-${keyBase.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
let contextField = null;
|
||||
let resizeFrame = 0;
|
||||
let lastContainerWidth = -1;
|
||||
|
||||
const headers = () =>
|
||||
[...table.querySelectorAll('thead tr:first-child th[data-field]')];
|
||||
|
||||
const fieldLabel = field => {
|
||||
const th = table.querySelector(
|
||||
`thead tr:first-child th[data-field="${CSS.escape(field)}"]`
|
||||
);
|
||||
return th?.dataset.label || th?.textContent?.trim() || field;
|
||||
};
|
||||
|
||||
const allFields = () => headers().map(th => th.dataset.field);
|
||||
|
||||
function getHidden() {
|
||||
try {
|
||||
const value = JSON.parse(window.AssetBrowserState?.get(hiddenKey) || '[]');
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setHidden(fields) {
|
||||
window.AssetBrowserState?.set(hiddenKey, JSON.stringify([...new Set(fields)]));
|
||||
}
|
||||
|
||||
function columnWidth(header) {
|
||||
const configured = Number.parseInt(header.dataset.columnWidth || '', 10);
|
||||
return Number.isFinite(configured) && configured > 0 ? configured : 180;
|
||||
}
|
||||
|
||||
function visibleTableWidth(hidden) {
|
||||
const fieldWidth = headers().reduce(
|
||||
(sum, header) =>
|
||||
hidden.has(header.dataset.field) ? sum : sum + columnWidth(header),
|
||||
0
|
||||
);
|
||||
const fixedWidth = Number.parseInt(table.dataset.fixedColumnWidth || '224', 10);
|
||||
return Math.max(640, fieldWidth + fixedWidth);
|
||||
}
|
||||
|
||||
function updateTopScrollbar(tableWidth) {
|
||||
if (!scroll || !topScroll || !topInner) return;
|
||||
topInner.style.width = `${tableWidth}px`;
|
||||
topScroll.hidden = tableWidth <= scroll.clientWidth + 1;
|
||||
}
|
||||
|
||||
function buildList() {
|
||||
if (!list) return;
|
||||
const hidden = new Set(getHidden());
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
allFields().forEach(field => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'checkbox-option';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.checked = !hidden.has(field);
|
||||
input.addEventListener('change', () =>
|
||||
input.checked ? showField(field) : hideField(field)
|
||||
);
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.textContent = fieldLabel(field);
|
||||
label.append(input, span);
|
||||
fragment.appendChild(label);
|
||||
});
|
||||
|
||||
list.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
function applyHidden() {
|
||||
const hidden = new Set(getHidden());
|
||||
let style = document.getElementById(styleId);
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
style.textContent = [...hidden]
|
||||
.map(field =>
|
||||
`#asset-table [data-field="${CSS.escape(field)}"]{display:none!important}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const tableWidth = visibleTableWidth(hidden);
|
||||
table.style.width = `${tableWidth}px`;
|
||||
table.style.minWidth = `${tableWidth}px`;
|
||||
table.dataset.calculatedWidth = String(tableWidth);
|
||||
buildList();
|
||||
|
||||
requestAnimationFrame(() => updateTopScrollbar(tableWidth));
|
||||
window.dispatchEvent(new Event('asset-table-columns-changed'));
|
||||
}
|
||||
|
||||
function hideField(field) {
|
||||
if (!field) return;
|
||||
const hidden = getHidden();
|
||||
const visibleFields = allFields().filter(item => !hidden.includes(item));
|
||||
if (visibleFields.length <= 1 || hidden.includes(field)) return;
|
||||
hidden.push(field);
|
||||
setHidden(hidden);
|
||||
applyHidden();
|
||||
}
|
||||
|
||||
function showField(field) {
|
||||
setHidden(getHidden().filter(item => item !== field));
|
||||
applyHidden();
|
||||
}
|
||||
|
||||
let syncing = false;
|
||||
scroll?.addEventListener('scroll', () => {
|
||||
if (syncing || !topScroll) return;
|
||||
syncing = true;
|
||||
topScroll.scrollLeft = scroll.scrollLeft;
|
||||
syncing = false;
|
||||
}, { passive: true });
|
||||
|
||||
topScroll?.addEventListener('scroll', () => {
|
||||
if (syncing || !scroll) return;
|
||||
syncing = true;
|
||||
scroll.scrollLeft = topScroll.scrollLeft;
|
||||
syncing = false;
|
||||
}, { passive: true });
|
||||
|
||||
headers().forEach(th => {
|
||||
th.addEventListener('contextmenu', event => {
|
||||
event.preventDefault();
|
||||
contextField = th.dataset.field;
|
||||
if (!menu) return;
|
||||
menu.hidden = false;
|
||||
const maxX = window.innerWidth - menu.offsetWidth - 8;
|
||||
const maxY = window.innerHeight - menu.offsetHeight - 8;
|
||||
menu.style.left = `${Math.max(8, Math.min(event.clientX, maxX))}px`;
|
||||
menu.style.top = `${Math.max(8, Math.min(event.clientY, maxY))}px`;
|
||||
});
|
||||
});
|
||||
|
||||
hideMenuButton?.addEventListener('click', () => {
|
||||
hideField(contextField);
|
||||
menu.hidden = true;
|
||||
});
|
||||
|
||||
openMenuButton?.addEventListener('click', () => {
|
||||
menu.hidden = true;
|
||||
buildList();
|
||||
dialog?.showModal();
|
||||
});
|
||||
|
||||
document.addEventListener('click', event => {
|
||||
if (menu && !menu.contains(event.target)) menu.hidden = true;
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && menu) menu.hidden = true;
|
||||
});
|
||||
|
||||
openButton?.addEventListener('click', () => {
|
||||
buildList();
|
||||
dialog?.showModal();
|
||||
});
|
||||
|
||||
showAllButton?.addEventListener('click', () => {
|
||||
setHidden([]);
|
||||
applyHidden();
|
||||
});
|
||||
|
||||
resetButton?.addEventListener('click', () => {
|
||||
setHidden([]);
|
||||
window.AssetBrowserState?.remove?.(keyBase);
|
||||
applyHidden();
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function scheduleScrollbarUpdate() {
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
const tableWidth = Number.parseInt(table.dataset.calculatedWidth || '0', 10);
|
||||
if (tableWidth) updateTopScrollbar(tableWidth);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('resize', scheduleScrollbarUpdate, { passive: true });
|
||||
|
||||
if ('ResizeObserver' in window && scroll) {
|
||||
const resizeObserver = new ResizeObserver(entries => {
|
||||
const width = Math.round(entries[0]?.contentRect?.width || 0);
|
||||
if (width === lastContainerWidth) return;
|
||||
lastContainerWidth = width;
|
||||
scheduleScrollbarUpdate();
|
||||
});
|
||||
resizeObserver.observe(scroll);
|
||||
}
|
||||
|
||||
window.addEventListener('asset-table-content-reloaded', event => {
|
||||
if (!event.detail?.table || event.detail.table === table) {
|
||||
applyHidden();
|
||||
}
|
||||
});
|
||||
|
||||
applyHidden();
|
||||
window.dispatchEvent(new CustomEvent('asset-table-columns-ready', {
|
||||
detail: { table }
|
||||
}));
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,112 @@
|
||||
(() => {
|
||||
function initialize() {
|
||||
const table = document.getElementById('asset-table');
|
||||
const openButton = document.getElementById('duplicate-search-button');
|
||||
const dialog = document.getElementById('duplicate-search-dialog');
|
||||
const select = document.getElementById('duplicate-field-select');
|
||||
const applyButton = document.getElementById('duplicate-apply-button');
|
||||
const clearButton = document.getElementById('duplicate-clear-button');
|
||||
const ignoreEmpty = document.getElementById('duplicate-ignore-empty');
|
||||
const caseInsensitive = document.getElementById('duplicate-case-insensitive');
|
||||
const summary = document.getElementById('duplicate-search-summary');
|
||||
if (!table || !openButton || !dialog || table.dataset.duplicateSearchReady === '1') return;
|
||||
table.dataset.duplicateSearchReady = '1';
|
||||
|
||||
const dataRows = () => [...table.tBodies[0].rows].filter(row => !row.querySelector('.empty-state'));
|
||||
const normallyVisible = row => {
|
||||
if (row.classList.contains('duplicate-hidden')) return false;
|
||||
if (row.hidden || row.classList.contains('hidden')) return false;
|
||||
const style = getComputedStyle(row);
|
||||
return style.display !== 'none' && style.visibility !== 'hidden';
|
||||
};
|
||||
const availableFields = () => [...table.querySelectorAll('thead tr:first-child th[data-field]')]
|
||||
.filter(th => th.style.display !== 'none')
|
||||
.map(th => ({
|
||||
field: th.dataset.field,
|
||||
label: th.dataset.label || th.textContent.trim()
|
||||
}));
|
||||
|
||||
function fillSelect() {
|
||||
const previous = select.value;
|
||||
select.innerHTML = '';
|
||||
availableFields().forEach(item => {
|
||||
const option = document.createElement('option');
|
||||
option.value = item.field;
|
||||
option.textContent = item.label;
|
||||
select.appendChild(option);
|
||||
});
|
||||
if ([...select.options].some(option => option.value === previous)) {
|
||||
select.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function clearDuplicateFilter() {
|
||||
dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match'));
|
||||
summary.hidden = true;
|
||||
window.dispatchEvent(new Event('asset-table-filter-changed'));
|
||||
}
|
||||
|
||||
function normalize(value) {
|
||||
let result = value.trim().replace(/\s+/g, ' ');
|
||||
if (caseInsensitive.checked) result = result.toLocaleLowerCase();
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyDuplicateFilter() {
|
||||
// Remove only our previous result first. Normal column filters stay intact.
|
||||
dataRows().forEach(row => row.classList.remove('duplicate-hidden', 'duplicate-match'));
|
||||
const field = select.value;
|
||||
if (!field) return;
|
||||
|
||||
const candidates = dataRows().filter(normallyVisible);
|
||||
const groups = new Map();
|
||||
candidates.forEach(row => {
|
||||
const cell = row.querySelector(`td[data-field="${CSS.escape(field)}"]`);
|
||||
const value = normalize(cell?.textContent || '');
|
||||
if (ignoreEmpty.checked && !value) return;
|
||||
const rows = groups.get(value) || [];
|
||||
rows.push(row);
|
||||
groups.set(value, rows);
|
||||
});
|
||||
|
||||
const duplicateRows = new Set();
|
||||
let duplicateGroups = 0;
|
||||
groups.forEach(rows => {
|
||||
if (rows.length > 1) {
|
||||
duplicateGroups += 1;
|
||||
rows.forEach(row => duplicateRows.add(row));
|
||||
}
|
||||
});
|
||||
|
||||
candidates.forEach(row => {
|
||||
if (duplicateRows.has(row)) {
|
||||
row.classList.add('duplicate-match');
|
||||
} else {
|
||||
row.classList.add('duplicate-hidden');
|
||||
}
|
||||
});
|
||||
|
||||
summary.textContent = `${duplicateRows.size} Datensätze in ${duplicateGroups} Dublettengruppen gefunden.`;
|
||||
summary.hidden = false;
|
||||
window.dispatchEvent(new Event('asset-table-filter-changed'));
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
openButton.addEventListener('click', () => {
|
||||
// Search always starts from the current normal filter result, not from
|
||||
// a previous duplicate result.
|
||||
clearDuplicateFilter();
|
||||
fillSelect();
|
||||
dialog.showModal();
|
||||
});
|
||||
applyButton.addEventListener('click', applyDuplicateFilter);
|
||||
clearButton.addEventListener('click', clearDuplicateFilter);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
window.addEventListener('asset-table-content-reloaded', initialize);
|
||||
})();
|
||||
@@ -0,0 +1,41 @@
|
||||
(() => {
|
||||
const tabs = Array.from(document.querySelectorAll('[data-inventory-tab]'));
|
||||
const panels = Array.from(document.querySelectorAll('[data-inventory-panel]'));
|
||||
tabs.forEach((tab) => tab.addEventListener('click', () => {
|
||||
const target = tab.dataset.inventoryTab;
|
||||
tabs.forEach((item) => item.classList.toggle('active', item === tab));
|
||||
panels.forEach((panel) => panel.classList.toggle('active', panel.dataset.inventoryPanel === target));
|
||||
}));
|
||||
|
||||
const filter = document.getElementById('software-inventory-filter');
|
||||
const table = document.getElementById('software-inventory-table');
|
||||
if (filter && table) {
|
||||
filter.addEventListener('input', () => {
|
||||
const needle = filter.value.trim().toLocaleLowerCase();
|
||||
table.querySelectorAll('tbody tr').forEach((row) => {
|
||||
row.hidden = needle && !row.textContent.toLocaleLowerCase().includes(needle);
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const tree = document.querySelector('.inventory-tree');
|
||||
if (!tree) return;
|
||||
const state = window.AssetBrowserState;
|
||||
const assetId = location.pathname.match(/\/assets\/(\d+)/)?.[1] || 'unknown';
|
||||
const storageKey = `inventory-tree:${assetId}`;
|
||||
let openPaths = new Set();
|
||||
if (state) {
|
||||
try { openPaths = new Set(JSON.parse(state.get(storageKey) || '[]')); } catch (_) {}
|
||||
}
|
||||
tree.querySelectorAll('details[data-inventory-tree-path]').forEach((node) => {
|
||||
const path = node.dataset.inventoryTreePath;
|
||||
node.open = openPaths.has(path);
|
||||
node.addEventListener('toggle', () => {
|
||||
if (!state) return;
|
||||
if (node.open) openPaths.add(path); else openPaths.delete(path);
|
||||
state.set(storageKey, JSON.stringify([...openPaths]));
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,61 @@
|
||||
(() => {
|
||||
const page = document.getElementById('asset-list-page');
|
||||
const loader = document.getElementById('asset-page-loader');
|
||||
const table = document.getElementById('asset-table');
|
||||
if (!page || !table) return;
|
||||
|
||||
const required = [
|
||||
'tableToolsReady',
|
||||
'browserStateReady',
|
||||
'columnOrderReady',
|
||||
'columnManagerReady'
|
||||
];
|
||||
let revealed = false;
|
||||
let fallbackTimer = 0;
|
||||
|
||||
function ready() {
|
||||
return required.every(marker => table.dataset[marker] === '1');
|
||||
}
|
||||
|
||||
function reveal() {
|
||||
if (revealed) return;
|
||||
revealed = true;
|
||||
clearTimeout(fallbackTimer);
|
||||
|
||||
const finish = () => requestAnimationFrame(() => {
|
||||
page.classList.remove('asset-list-initializing');
|
||||
page.setAttribute('aria-busy', 'false');
|
||||
document.documentElement.classList.remove('asset-page-booting');
|
||||
loader?.setAttribute('hidden', '');
|
||||
window.dispatchEvent(new CustomEvent('asset-page-ready', {
|
||||
detail: { table }
|
||||
}));
|
||||
});
|
||||
|
||||
if (document.fonts?.ready) {
|
||||
document.fonts.ready.then(finish, finish);
|
||||
} else {
|
||||
finish();
|
||||
}
|
||||
}
|
||||
|
||||
function check() {
|
||||
if (ready()) reveal();
|
||||
}
|
||||
|
||||
[
|
||||
'asset-table-structure-ready',
|
||||
'asset-table-state-ready',
|
||||
'asset-table-column-order-ready',
|
||||
'asset-table-columns-ready',
|
||||
'asset-list-count-updated'
|
||||
].forEach(name => window.addEventListener(name, check));
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', check, { once: true });
|
||||
} else {
|
||||
check();
|
||||
}
|
||||
|
||||
fallbackTimer = window.setTimeout(reveal, 2500);
|
||||
})();
|
||||
@@ -0,0 +1,103 @@
|
||||
(() => {
|
||||
const prefix = '[AssetManager][Performance]';
|
||||
const pageStart = performance.now();
|
||||
const active = new Map();
|
||||
const results = [];
|
||||
|
||||
const now = () => Math.round((performance.now() - pageStart) * 10) / 10;
|
||||
const round = value => Math.round(value * 10) / 10;
|
||||
|
||||
function start(name, details = {}) {
|
||||
active.set(name, performance.now());
|
||||
console.debug(prefix, `+${now()} ms`, `START ${name}`, details);
|
||||
}
|
||||
|
||||
function end(name, details = {}) {
|
||||
const started = active.get(name);
|
||||
const duration = started == null ? null : performance.now() - started;
|
||||
active.delete(name);
|
||||
const result = {
|
||||
Modul: name,
|
||||
Start_ms: started == null ? null : round(started - pageStart),
|
||||
Dauer_ms: duration == null ? null : round(duration),
|
||||
...details
|
||||
};
|
||||
results.push(result);
|
||||
const logger = duration != null && duration >= 50 ? console.warn : console.debug;
|
||||
logger(prefix, `+${now()} ms`, `ENDE ${name}`, result);
|
||||
return duration;
|
||||
}
|
||||
|
||||
function measure(name, callback, details = {}) {
|
||||
const started = performance.now();
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
const duration = performance.now() - started;
|
||||
const result = {
|
||||
Modul: name,
|
||||
Start_ms: round(started - pageStart),
|
||||
Dauer_ms: round(duration),
|
||||
...details
|
||||
};
|
||||
results.push(result);
|
||||
const logger = duration >= 50 ? console.warn : console.debug;
|
||||
logger(prefix, `+${now()} ms`, name, result);
|
||||
}
|
||||
}
|
||||
|
||||
async function measureAsync(name, callback, details = {}) {
|
||||
const started = performance.now();
|
||||
try {
|
||||
return await callback();
|
||||
} finally {
|
||||
const duration = performance.now() - started;
|
||||
const result = {
|
||||
Modul: name,
|
||||
Start_ms: round(started - pageStart),
|
||||
Dauer_ms: round(duration),
|
||||
...details
|
||||
};
|
||||
results.push(result);
|
||||
const logger = duration >= 50 ? console.warn : console.debug;
|
||||
logger(prefix, `+${now()} ms`, name, result);
|
||||
}
|
||||
}
|
||||
|
||||
function report(name, duration, details = {}, level = null) {
|
||||
const result = {
|
||||
Modul: name,
|
||||
Start_ms: details.Start_ms ?? null,
|
||||
Dauer_ms: round(duration),
|
||||
...details
|
||||
};
|
||||
delete result.Start_ms;
|
||||
result.Start_ms = details.Start_ms ?? null;
|
||||
results.push(result);
|
||||
|
||||
try {
|
||||
const safeName = name.replace(/[^a-zA-Z0-9:_-]+/g, '-');
|
||||
performance.mark(`${safeName}:end`);
|
||||
performance.measure(safeName, {
|
||||
start: Math.max(0, performance.now() - duration),
|
||||
end: performance.now()
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
const logger = level === 'warn' || (level == null && duration >= 50)
|
||||
? console.warn
|
||||
: console.debug;
|
||||
logger(prefix, `+${now()} ms`, name, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function summary() {
|
||||
const sorted = [...results].sort((a, b) => (b.Dauer_ms || 0) - (a.Dauer_ms || 0));
|
||||
console.groupCollapsed(`${prefix} Zusammenfassung – langsamste Vorgänge`);
|
||||
console.table(sorted.slice(0, 30));
|
||||
console.groupEnd();
|
||||
return sorted;
|
||||
}
|
||||
|
||||
window.AssetPerf = { start, end, measure, measureAsync, report, summary, results };
|
||||
})();
|
||||
@@ -0,0 +1,113 @@
|
||||
(() => {
|
||||
const perf = window.AssetPerf;
|
||||
perf?.start('browser-state:gesamtes Modul');
|
||||
const persistent = document.body?.dataset.persistBrowserState === 'true';
|
||||
const storage = persistent ? window.localStorage : window.sessionStorage;
|
||||
const prefix = 'assetmanager:';
|
||||
|
||||
const api = {
|
||||
persistent,
|
||||
get(key) {
|
||||
try { return storage.getItem(prefix + key); } catch (_) { return null; }
|
||||
},
|
||||
set(key, value) {
|
||||
try { storage.setItem(prefix + key, value); } catch (_) {}
|
||||
},
|
||||
remove(key) {
|
||||
try { storage.removeItem(prefix + key); } catch (_) {}
|
||||
}
|
||||
};
|
||||
window.AssetBrowserState = api;
|
||||
|
||||
function tableKey(table, index) {
|
||||
return table.dataset.storageKey || table.id || `table-${location.pathname}-${index}`;
|
||||
}
|
||||
|
||||
function filterControls(table) {
|
||||
return [...table.querySelectorAll('thead input, thead select')].filter(control => {
|
||||
const type = (control.type || '').toLowerCase();
|
||||
return !['checkbox', 'radio', 'button', 'submit'].includes(type);
|
||||
});
|
||||
}
|
||||
|
||||
function controlKey(control, index) {
|
||||
const cell = control.closest('th');
|
||||
return control.name || control.dataset.field || cell?.dataset.field || `filter-${index}`;
|
||||
}
|
||||
|
||||
function restoreTable(table, index) {
|
||||
if (table.dataset.browserStateReady === '1') return;
|
||||
if (table.dataset.serverFilters === '1') {
|
||||
table.dataset.browserStateReady = '1';
|
||||
return;
|
||||
}
|
||||
const restoreStarted = performance.now();
|
||||
const controls = filterControls(table);
|
||||
if (!controls.length) return;
|
||||
|
||||
table.dataset.browserStateReady = '1';
|
||||
const key = `${tableKey(table, index)}:filters`;
|
||||
let values = {};
|
||||
try { values = JSON.parse(api.get(key) || '{}') || {}; } catch (_) {}
|
||||
|
||||
controls.forEach((control, controlIndex) => {
|
||||
const field = controlKey(control, controlIndex);
|
||||
if (Object.prototype.hasOwnProperty.call(values, field)) {
|
||||
// Restore without dispatching one input/change pair per column.
|
||||
control.value = values[field];
|
||||
}
|
||||
const save = () => {
|
||||
const current = {};
|
||||
filterControls(table).forEach((item, itemIndex) => {
|
||||
current[controlKey(item, itemIndex)] = item.value;
|
||||
});
|
||||
api.set(key, JSON.stringify(current));
|
||||
};
|
||||
control.addEventListener('input', save);
|
||||
control.addEventListener('change', save);
|
||||
});
|
||||
|
||||
// One filtering pass after every stored value is in place.
|
||||
if (typeof table.assetApplyFilters === 'function') {
|
||||
table.assetApplyFilters();
|
||||
}
|
||||
table.dataset.browserStateRestored = '1';
|
||||
window.dispatchEvent(new CustomEvent('asset-table-state-ready', { detail: { table } }));
|
||||
const duration = performance.now() - restoreStarted;
|
||||
const logger = duration >= 50 ? console.warn : console.debug;
|
||||
logger('[AssetManager][Performance]', 'browser-state:restoreTable', {
|
||||
dauer_ms: Math.round(duration * 10) / 10,
|
||||
filter: controls.length,
|
||||
gespeicherte_werte: Object.keys(values).length
|
||||
});
|
||||
}
|
||||
|
||||
function initializeTables() {
|
||||
document.querySelectorAll('table').forEach(restoreTable);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initializeTables, { once: true });
|
||||
} else {
|
||||
initializeTables();
|
||||
}
|
||||
|
||||
// Filter controls are added by table-tools.js. Initialize only on its explicit
|
||||
// completion event instead of observing every DOM mutation in the document.
|
||||
window.addEventListener('asset-table-structure-ready', initializeTables);
|
||||
|
||||
// A programmatic reset does not emit input/change events. Remove the
|
||||
// corresponding persisted filter state explicitly so it cannot return after
|
||||
// a page refresh.
|
||||
window.addEventListener('asset-table-filters-cleared', event => {
|
||||
const table = event.detail?.table;
|
||||
if (!(table instanceof HTMLTableElement)) return;
|
||||
|
||||
const tables = [...document.querySelectorAll('table')];
|
||||
const index = tables.indexOf(table);
|
||||
const key = `${tableKey(table, index >= 0 ? index : 0)}:filters`;
|
||||
api.remove(key);
|
||||
});
|
||||
|
||||
perf?.end('browser-state:gesamtes Modul');
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
(() => {
|
||||
const dialog = document.getElementById('issue-dialog');
|
||||
const form = document.getElementById('issue-form');
|
||||
if (!dialog || !form) return;
|
||||
const dateInput = form.querySelector('input[name="assigned_on"]');
|
||||
if (dateInput && !dateInput.value) dateInput.value = new Date().toISOString().slice(0, 10);
|
||||
document.querySelectorAll('[data-issue-action]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
form.action = button.dataset.issueAction;
|
||||
const target = document.getElementById('issue-return-to');
|
||||
if (target) target.value = button.dataset.returnTo || 'detail';
|
||||
dialog.showModal();
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,132 @@
|
||||
(() => {
|
||||
const perf = window.AssetPerf;
|
||||
perf?.start('list-tools:gesamtes Modul');
|
||||
const tables = new Map();
|
||||
|
||||
function isDataRow(row) {
|
||||
if (!row || row.closest('thead')) return false;
|
||||
if (row.classList.contains('column-filter-row') || row.classList.contains('filter-row')) return false;
|
||||
if (row.querySelector('.empty-state')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function isVisible(row) {
|
||||
if (row.hidden || row.classList.contains('hidden')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function countTarget(table, index) {
|
||||
const toolbar = table.closest('main')?.querySelector('.toolbar');
|
||||
if (!toolbar) return null;
|
||||
|
||||
let actions = toolbar.querySelector('.toolbar-actions');
|
||||
if (!actions) {
|
||||
actions = document.createElement('div');
|
||||
actions.className = 'toolbar-actions';
|
||||
toolbar.appendChild(actions);
|
||||
}
|
||||
|
||||
let target = actions.querySelector(`[data-table-count-for="${index}"]`);
|
||||
if (!target) {
|
||||
target = document.createElement('span');
|
||||
target.className = 'record-count';
|
||||
target.dataset.tableCountFor = String(index);
|
||||
actions.insertBefore(target, actions.firstChild);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function update(table, index) {
|
||||
const updateStarted = performance.now();
|
||||
const rows = [...table.querySelectorAll('tbody tr')].filter(isDataRow);
|
||||
const visible = rows.filter(isVisible).length;
|
||||
const target = countTarget(table, index);
|
||||
if (target) {
|
||||
const template = document.body.dataset.recordCountTemplate || 'Angezeigt: {visible} von {total} Datensätzen';
|
||||
target.textContent = template
|
||||
.replace('{visible}', String(visible))
|
||||
.replace('{total}', String(rows.length));
|
||||
target.title = document.body.dataset.recordCountTitle || '';
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('asset-list-count-updated', {
|
||||
detail: { table, visible, total: rows.length }
|
||||
}));
|
||||
const duration = performance.now() - updateStarted;
|
||||
const logger = duration >= 50 ? console.warn : console.debug;
|
||||
logger('[AssetManager][Performance]', 'list-tools:update', {
|
||||
dauer_ms: Math.round(duration * 10) / 10,
|
||||
sichtbar: visible,
|
||||
gesamt: rows.length
|
||||
});
|
||||
}
|
||||
|
||||
function schedule(table, index) {
|
||||
const state = tables.get(table) || {};
|
||||
if (state.frame) cancelAnimationFrame(state.frame);
|
||||
state.frame = requestAnimationFrame(() => {
|
||||
state.frame = null;
|
||||
update(table, index);
|
||||
});
|
||||
tables.set(table, state);
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
document.querySelectorAll('table.data-table').forEach((table, index) => {
|
||||
if (table.dataset.listToolsReady === '1') {
|
||||
schedule(table, index);
|
||||
return;
|
||||
}
|
||||
table.dataset.listToolsReady = '1';
|
||||
schedule(table, index);
|
||||
|
||||
table.addEventListener('input', () => setTimeout(() => schedule(table, index), 0));
|
||||
table.addEventListener('change', () => setTimeout(() => schedule(table, index), 0));
|
||||
|
||||
const body = table.tBodies[0];
|
||||
if (body) {
|
||||
const observer = new MutationObserver(mutations => {
|
||||
const started = performance.now();
|
||||
schedule(table, index);
|
||||
const duration = performance.now() - started;
|
||||
console.debug('[AssetManager][Performance]', 'list-tools:MutationObserver', {
|
||||
dauer_ms: Math.round(duration * 10) / 10,
|
||||
mutationen: mutations.length
|
||||
});
|
||||
});
|
||||
observer.observe(body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['hidden', 'style', 'class']
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (document.getElementById('asset-table')) {
|
||||
document.body.classList.add('single-scroll-list');
|
||||
} else {
|
||||
document.body.classList.remove('single-scroll-list');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('input', () => {
|
||||
document.querySelectorAll('table.data-table').forEach((table, index) => {
|
||||
setTimeout(() => schedule(table, index), 0);
|
||||
});
|
||||
});
|
||||
document.addEventListener('change', () => {
|
||||
document.querySelectorAll('table.data-table').forEach((table, index) => {
|
||||
setTimeout(() => schedule(table, index), 0);
|
||||
});
|
||||
});
|
||||
window.addEventListener('asset-table-filter-changed', initialize);
|
||||
window.addEventListener('asset-table-content-reloaded', initialize);
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
|
||||
perf?.end('list-tools:gesamtes Modul');
|
||||
})();
|
||||
@@ -0,0 +1,74 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const language = document.documentElement.lang || navigator.language || 'de-DE';
|
||||
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const formatter = new Intl.DateTimeFormat(language, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium'
|
||||
});
|
||||
|
||||
function parseUtc(value) {
|
||||
if (!value) return null;
|
||||
let normalized = String(value).trim();
|
||||
if (!normalized) return null;
|
||||
// Project database timestamps without an offset are UTC by convention.
|
||||
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(normalized)) {
|
||||
normalized = normalized.replace(' ', 'T') + 'Z';
|
||||
}
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatUtc(value, fallback = '') {
|
||||
const date = parseUtc(value);
|
||||
return date ? formatter.format(date) : (fallback || value || '');
|
||||
}
|
||||
|
||||
function localizeElement(element) {
|
||||
const value = element.getAttribute('datetime') || element.dataset.utc;
|
||||
if (!value) return;
|
||||
const formatted = formatUtc(value, element.textContent);
|
||||
element.textContent = formatted;
|
||||
element.title = `${value} UTC · ${zone}`;
|
||||
element.dataset.localTimeApplied = 'true';
|
||||
const cell = element.closest('[data-sort-value]');
|
||||
if (cell) cell.dataset.sortValue = value;
|
||||
}
|
||||
|
||||
const isoPattern = /(?<!\d)(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)(?!\d)/g;
|
||||
function localizeLogText(element) {
|
||||
if (!element || element.dataset.localizingLog === 'true') return;
|
||||
const original = element.textContent || '';
|
||||
const converted = original.replace(isoPattern, match => formatUtc(match, match));
|
||||
if (converted !== original) {
|
||||
element.dataset.localizingLog = 'true';
|
||||
element.textContent = converted;
|
||||
delete element.dataset.localizingLog;
|
||||
}
|
||||
element.title = zone;
|
||||
}
|
||||
|
||||
function scan(root = document) {
|
||||
if (root.matches?.('time[data-utc], time[datetime].local-time')) localizeElement(root);
|
||||
root.querySelectorAll?.('time[data-utc], time[datetime].local-time').forEach(localizeElement);
|
||||
if (root.matches?.('[data-localize-log]')) localizeLogText(root);
|
||||
root.querySelectorAll?.('[data-localize-log]').forEach(localizeLogText);
|
||||
}
|
||||
|
||||
window.AssetManagerTime = {formatUtc, parseUtc, zone, scan};
|
||||
scan(document);
|
||||
|
||||
const observer = new MutationObserver(mutations => {
|
||||
for (const mutation of mutations) {
|
||||
mutation.addedNodes.forEach(node => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) scan(node);
|
||||
});
|
||||
const owner = mutation.target.nodeType === Node.ELEMENT_NODE
|
||||
? mutation.target.closest?.('[data-localize-log]')
|
||||
: mutation.target.parentElement?.closest?.('[data-localize-log]');
|
||||
if (owner) localizeLogText(owner);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, {childList: true, subtree: true, characterData: true});
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
(() => {
|
||||
const elements = [...document.querySelectorAll('[data-presence-asset]')];
|
||||
if (!elements.length) return;
|
||||
|
||||
function formattedDate(value) {
|
||||
return value ? (window.AssetManagerTime?.formatUtc(value, value) || value) : '–';
|
||||
}
|
||||
|
||||
function updateElement(element, item) {
|
||||
const state = ['online', 'offline'].includes(item.state) ? item.state : 'unknown';
|
||||
element.classList.remove('presence-online', 'presence-offline', 'presence-unknown');
|
||||
element.classList.add(`presence-${state}`);
|
||||
element.dataset.filterValue = element.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] || state;
|
||||
const label = element.querySelector('[data-presence-label]');
|
||||
if (label) {
|
||||
label.textContent =
|
||||
element.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] || state;
|
||||
}
|
||||
const lastSeenText = formattedDate(item.last_seen_at);
|
||||
const lastSeenPrefix = element.dataset.lastSeenLabel || 'Last seen';
|
||||
element.title = `${label?.textContent || state} · ${lastSeenPrefix}: ${lastSeenText}`;
|
||||
|
||||
const meta = document.querySelector(`[data-presence-meta="${CSS.escape(String(item.asset_id))}"]`);
|
||||
if (meta) {
|
||||
const stateElement = meta.querySelector('[data-presence-meta-state]');
|
||||
const updatedElement = meta.querySelector('[data-presence-updated]');
|
||||
const lastOnlineElement = meta.querySelector('[data-presence-last-online]');
|
||||
const lastSeenElement = meta.querySelector('[data-presence-last-seen]');
|
||||
if (stateElement) stateElement.textContent = label?.textContent || state;
|
||||
if (updatedElement) updatedElement.textContent = formattedDate(item.updated_at);
|
||||
if (lastOnlineElement) lastOnlineElement.textContent = formattedDate(item.last_online_at);
|
||||
if (lastSeenElement) lastSeenElement.textContent = formattedDate(item.last_seen_at);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPresence() {
|
||||
try {
|
||||
const response = await fetch('/api/assets/presence', {cache: 'no-store'});
|
||||
if (!response.ok) return;
|
||||
const payload = await response.json();
|
||||
const byId = new Map((payload.items || []).map(item => [String(item.asset_id), item]));
|
||||
elements.forEach(element => {
|
||||
const item = byId.get(String(element.dataset.presenceAsset));
|
||||
if (item) updateElement(element, item);
|
||||
});
|
||||
|
||||
document.querySelectorAll('table.data-table').forEach(table => {
|
||||
table.assetRebuildFilterCache?.();
|
||||
table.assetApplyFilters?.();
|
||||
});
|
||||
} catch (_) {
|
||||
// Keep the last known state visible when the API is temporarily unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
refreshPresence();
|
||||
window.setInterval(refreshPresence, 5000);
|
||||
})();
|
||||
@@ -0,0 +1,44 @@
|
||||
(()=>{
|
||||
function init(root=document){
|
||||
root.querySelectorAll('[data-software-selection]').forEach(container=>{
|
||||
if(container.dataset.selectionReady==='1') return;
|
||||
container.dataset.selectionReady='1';
|
||||
const table=container.querySelector('table');
|
||||
const form=container.querySelector('form[data-software-delete-form]');
|
||||
const master=container.querySelector('[data-software-select-all]');
|
||||
const button=container.querySelector('[data-software-delete-button]');
|
||||
const countNode=container.querySelector('[data-software-selection-count]');
|
||||
if(!table||!form) return;
|
||||
const boxes=()=>[...table.querySelectorAll('tbody input[data-software-entry-checkbox]')];
|
||||
const visibleBoxes=()=>boxes().filter(box=>!box.closest('tr')?.hidden);
|
||||
const update=()=>{
|
||||
const selected=boxes().filter(box=>box.checked);
|
||||
if(button) button.disabled=selected.length===0;
|
||||
if(countNode) countNode.textContent=String(selected.length);
|
||||
if(master){
|
||||
const visible=visibleBoxes();
|
||||
const selectedVisible=visible.filter(box=>box.checked).length;
|
||||
master.checked=visible.length>0&&selectedVisible===visible.length;
|
||||
master.indeterminate=selectedVisible>0&&selectedVisible<visible.length;
|
||||
master.disabled=visible.length===0;
|
||||
}
|
||||
};
|
||||
master?.addEventListener('change',()=>{ visibleBoxes().forEach(box=>box.checked=master.checked); update(); });
|
||||
table.addEventListener('change',event=>{ if(event.target.matches('[data-software-entry-checkbox]')) update(); });
|
||||
form.addEventListener('submit',event=>{
|
||||
const selected=boxes().filter(box=>box.checked);
|
||||
if(!selected.length){ event.preventDefault(); return; }
|
||||
form.querySelectorAll('input[data-generated-entry-id]').forEach(node=>node.remove());
|
||||
selected.forEach(box=>{ const input=document.createElement('input'); input.type='hidden'; input.name='entry_ids'; input.value=box.value; input.dataset.generatedEntryId='1'; form.appendChild(input); });
|
||||
const template=form.dataset.confirmTemplate||'Delete {count} selected software inventory entries?';
|
||||
const warning=form.dataset.inventoryOnlyWarning||'';
|
||||
const message=template.replace('{count}',String(selected.length))+(warning?'\n\n'+warning:'');
|
||||
if(!window.confirm(message)) event.preventDefault();
|
||||
});
|
||||
window.addEventListener('asset-table-filter-changed',event=>{ if(event.detail?.table===table) update(); });
|
||||
update();
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded',()=>init());
|
||||
window.AssetManagerSoftwareSelection={init};
|
||||
})();
|
||||
@@ -0,0 +1,74 @@
|
||||
(() => {
|
||||
function init() {
|
||||
const table = document.querySelector('table[data-storage-key="software-jobs"]');
|
||||
const master = document.getElementById('select-all-restartable-jobs');
|
||||
const button = document.getElementById('restart-selected-jobs-button');
|
||||
const form = document.getElementById('restart-selected-jobs-form');
|
||||
const idContainer = document.getElementById('restart-selected-job-ids');
|
||||
if (!table || !master || !button || !form || !idContainer) return;
|
||||
|
||||
const boxes = () => [...table.querySelectorAll('tbody .software-job-restart-select:not(:disabled)')];
|
||||
const isVisible = box => {
|
||||
const row = box.closest('tr');
|
||||
return Boolean(row && !row.hidden && getComputedStyle(row).display !== 'none');
|
||||
};
|
||||
const visibleBoxes = () => boxes().filter(isVisible);
|
||||
const selectedBoxes = () => boxes().filter(box => box.checked && isVisible(box));
|
||||
|
||||
function update() {
|
||||
boxes().filter(box => !isVisible(box)).forEach(box => { box.checked = false; });
|
||||
const visible = visibleBoxes();
|
||||
const selected = selectedBoxes();
|
||||
const count = selected.length;
|
||||
const baseLabel = button.dataset.baseLabel || button.textContent.trim();
|
||||
|
||||
button.disabled = count === 0;
|
||||
button.textContent = count > 0 ? `${baseLabel} (${count})` : baseLabel;
|
||||
master.disabled = visible.length === 0;
|
||||
master.checked = visible.length > 0 && count === visible.length;
|
||||
master.indeterminate = count > 0 && count < visible.length;
|
||||
}
|
||||
|
||||
master.addEventListener('change', () => {
|
||||
visibleBoxes().forEach(box => { box.checked = master.checked; });
|
||||
update();
|
||||
});
|
||||
|
||||
table.addEventListener('change', event => {
|
||||
if (event.target.matches('.software-job-restart-select')) update();
|
||||
});
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
const selected = selectedBoxes();
|
||||
if (!selected.length) return;
|
||||
const template = form.dataset.confirmTemplate || 'Restart {count} selected jobs?';
|
||||
if (!window.confirm(template.replace('{count}', String(selected.length)))) return;
|
||||
|
||||
idContainer.replaceChildren();
|
||||
selected.forEach(box => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'job_ids';
|
||||
input.value = box.value;
|
||||
idContainer.appendChild(input);
|
||||
});
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
table.querySelectorAll('tbody tr').forEach(row => {
|
||||
observer.observe(row, {attributes: true, attributeFilter: ['style', 'class', 'hidden']});
|
||||
});
|
||||
window.addEventListener('asset-table-filter-changed', event => {
|
||||
if (!event.detail?.table || event.detail.table === table) update();
|
||||
});
|
||||
window.addEventListener('software-job-status-changed', update);
|
||||
update();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, {once: true});
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,103 @@
|
||||
(() => {
|
||||
const terminalStatuses = new Set(['success', 'failed', 'partial', 'timeout', 'cancelled']);
|
||||
const dispatchingStatuses = new Set(['sending', 'running']);
|
||||
const bulkBlockedStatuses = new Set(['success', 'sending', 'running']);
|
||||
|
||||
function init() {
|
||||
const table = document.querySelector('table[data-storage-key="software-jobs"]');
|
||||
if (!table) return;
|
||||
|
||||
const rows = () => [...table.querySelectorAll('tbody tr[data-job-id]')];
|
||||
const activeRows = () => rows().filter(row => !terminalStatuses.has(row.dataset.jobStatus || 'created'));
|
||||
|
||||
function renderRestartCell(row, status) {
|
||||
const cell = row.querySelector('[data-job-restart-cell]');
|
||||
if (!cell) return;
|
||||
const currentlyBlocked = Boolean(cell.querySelector('.muted'));
|
||||
const mustBlock = dispatchingStatuses.has(status);
|
||||
if (currentlyBlocked === mustBlock) return;
|
||||
|
||||
cell.replaceChildren();
|
||||
if (mustBlock) {
|
||||
const placeholder = document.createElement('span');
|
||||
placeholder.className = 'muted';
|
||||
placeholder.textContent = '—';
|
||||
cell.appendChild(placeholder);
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = `/jobs/${row.dataset.jobId}/restart`;
|
||||
form.className = 'inline-form';
|
||||
form.addEventListener('submit', event => {
|
||||
const message = table.dataset.restartConfirm || '';
|
||||
if (message && !window.confirm(message)) event.preventDefault();
|
||||
});
|
||||
const button = document.createElement('button');
|
||||
button.type = 'submit';
|
||||
button.className = 'button button-secondary button-small';
|
||||
button.textContent = table.dataset.restartLabel || '↻';
|
||||
form.appendChild(button);
|
||||
cell.appendChild(form);
|
||||
}
|
||||
|
||||
function applyStatus(item) {
|
||||
const row = table.querySelector(`tbody tr[data-job-id="${CSS.escape(String(item.id))}"]`);
|
||||
if (!row) return;
|
||||
const status = String(item.status || 'created');
|
||||
const previous = row.dataset.jobStatus || '';
|
||||
row.dataset.jobStatus = status;
|
||||
|
||||
const field = row.querySelector('[data-job-status-field]');
|
||||
if (field) {
|
||||
field.textContent = item.status_label || status;
|
||||
field.className = `job-status job-status-${status}`;
|
||||
}
|
||||
|
||||
const checkbox = row.querySelector('.software-job-restart-select');
|
||||
if (checkbox) {
|
||||
const hasNode = row.dataset.jobHasNode === '1';
|
||||
checkbox.disabled = !hasNode || bulkBlockedStatuses.has(status);
|
||||
if (checkbox.disabled) checkbox.checked = false;
|
||||
}
|
||||
renderRestartCell(row, status);
|
||||
|
||||
if (previous !== status) {
|
||||
window.dispatchEvent(new CustomEvent('software-job-status-changed', {detail: {row, status}}));
|
||||
}
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
async function refresh() {
|
||||
const active = activeRows();
|
||||
if (!active.length) return;
|
||||
const ids = active.map(row => Number(row.dataset.jobId)).filter(Number.isInteger);
|
||||
try {
|
||||
const response = await fetch('/api/software-jobs/statuses', {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(ids),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
(payload.jobs || []).forEach(applyStatus);
|
||||
if (activeRows().length) timer = window.setTimeout(refresh, 3000);
|
||||
} catch (_) {
|
||||
timer = window.setTimeout(refresh, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeRows().length) timer = window.setTimeout(refresh, 1200);
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (timer) window.clearTimeout(timer);
|
||||
}, {once: true});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, {once: true});
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,503 @@
|
||||
(() => {
|
||||
const collator = new Intl.Collator(document.documentElement.lang || 'de', {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
|
||||
function controlValue(control) {
|
||||
if (control.tagName === 'SELECT') {
|
||||
const selected = control.options?.[control.selectedIndex];
|
||||
return [
|
||||
control.value || '',
|
||||
selected?.textContent || ''
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
if (control.type === 'checkbox' || control.type === 'radio') {
|
||||
return control.checked
|
||||
? [
|
||||
control.value || '',
|
||||
control.getAttribute('aria-label') || '',
|
||||
control.closest('label')?.textContent || ''
|
||||
].join(' ')
|
||||
: '';
|
||||
}
|
||||
|
||||
return [
|
||||
control.value || '',
|
||||
control.getAttribute('placeholder') || '',
|
||||
control.getAttribute('aria-label') || ''
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function textOutsideControls(cell) {
|
||||
const clone = cell.cloneNode(true);
|
||||
clone.querySelectorAll('input, select, textarea, option').forEach(node => node.remove());
|
||||
return (clone.textContent || '').trim();
|
||||
}
|
||||
|
||||
function textValue(cell) {
|
||||
if (!cell) return '';
|
||||
|
||||
const explicitFilterValue = cell.dataset.filterValue || '';
|
||||
|
||||
if (cell.dataset.sortValue !== undefined) {
|
||||
return `${cell.dataset.sortValue} ${explicitFilterValue}`.trim();
|
||||
}
|
||||
|
||||
const controls = [...cell.querySelectorAll('input, select, textarea')];
|
||||
if (controls.length) {
|
||||
const controlText = controls.map(controlValue).join(' ').trim();
|
||||
const additionalText = textOutsideControls(cell);
|
||||
return `${explicitFilterValue} ${controlText} ${additionalText}`.trim();
|
||||
}
|
||||
|
||||
return `${explicitFilterValue} ${cell.textContent || ''}`.trim();
|
||||
}
|
||||
|
||||
function parsedValue(value) {
|
||||
const text = value.trim();
|
||||
if (!text) return { type: 'empty', value: '' };
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}(?:[ T].*)?$/.test(text)) {
|
||||
const timestamp = Date.parse(text.replace(' ', 'T'));
|
||||
if (!Number.isNaN(timestamp)) return { type: 'date', value: timestamp };
|
||||
}
|
||||
|
||||
if (/^\d{1,2}\.\d{1,2}\.\d{4}$/.test(text)) {
|
||||
const [day, month, year] = text.split('.').map(Number);
|
||||
return { type: 'date', value: new Date(year, month - 1, day).getTime() };
|
||||
}
|
||||
|
||||
if (/^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(text)) {
|
||||
return { type: 'ipv4', value: text.split('.').map(Number) };
|
||||
}
|
||||
|
||||
const numeric = text.replace(/\s/g, '').replace(',', '.');
|
||||
if (/^[-+]?\d+(?:\.\d+)?$/.test(numeric)) {
|
||||
return { type: 'number', value: Number(numeric) };
|
||||
}
|
||||
|
||||
return { type: 'text', value: text };
|
||||
}
|
||||
|
||||
function compareValues(a, b) {
|
||||
const left = parsedValue(a);
|
||||
const right = parsedValue(b);
|
||||
|
||||
if (left.type === 'empty' && right.type !== 'empty') return 1;
|
||||
if (right.type === 'empty' && left.type !== 'empty') return -1;
|
||||
|
||||
if (left.type === right.type && (left.type === 'number' || left.type === 'date')) {
|
||||
return left.value - right.value;
|
||||
}
|
||||
|
||||
if (left.type === 'ipv4' && right.type === 'ipv4') {
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const difference = left.value[index] - right.value[index];
|
||||
if (difference) return difference;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
return collator.compare(String(left.value), String(right.value));
|
||||
}
|
||||
|
||||
function initTable(table) {
|
||||
const thead = table.tHead;
|
||||
const tbody = table.tBodies[0];
|
||||
if (!thead || !tbody || !thead.rows.length) return;
|
||||
if (table.dataset.tableToolsReady === '1') return;
|
||||
|
||||
const headerRow = thead.rows[0];
|
||||
|
||||
const isAssetTable = table.id === 'asset-table';
|
||||
const usesServerFilters = table.dataset.serverFilters === '1';
|
||||
let serverFilterTimer = null;
|
||||
|
||||
// Normal tables get stable synthetic keys. The asset table already has
|
||||
// real data-field keys on its movable columns; selection and action
|
||||
// columns must remain without a field key.
|
||||
if (!isAssetTable) {
|
||||
[...headerRow.cells].forEach((header, index) => {
|
||||
if (!header.dataset.field) {
|
||||
header.dataset.field = `__col_${index}`;
|
||||
header.dataset.syntheticField = '1';
|
||||
}
|
||||
});
|
||||
|
||||
[...tbody.rows].forEach(row => {
|
||||
[...row.cells].forEach((cell, index) => {
|
||||
const header = headerRow.cells[index];
|
||||
if (header && !cell.dataset.field) {
|
||||
cell.dataset.field = header.dataset.field;
|
||||
cell.dataset.syntheticField = '1';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const filterRow = document.createElement('tr');
|
||||
filterRow.className = 'column-filter-row';
|
||||
filterRow.dataset.tableFilterRow = '1';
|
||||
|
||||
const filterDefinitions = [];
|
||||
|
||||
function filterStatusTarget() {
|
||||
const wrapper = table.closest(
|
||||
'#asset-table-scroll, .management-table-scroll, .table-scroll, .table-wrap, .sticky-table'
|
||||
);
|
||||
return wrapper || table;
|
||||
}
|
||||
|
||||
function ensureFilterStatus() {
|
||||
let status = table._filterStatusElement;
|
||||
if (status?.isConnected) return status;
|
||||
|
||||
status = document.createElement('div');
|
||||
status.className = 'table-filter-status';
|
||||
status.hidden = true;
|
||||
status.setAttribute('role', 'status');
|
||||
status.setAttribute('aria-live', 'polite');
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'table-filter-status-icon';
|
||||
icon.textContent = '⌕';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const label = document.createElement('strong');
|
||||
label.className = 'table-filter-status-label';
|
||||
|
||||
const details = document.createElement('span');
|
||||
details.className = 'table-filter-status-details';
|
||||
|
||||
const clearButton = document.createElement('button');
|
||||
clearButton.type = 'button';
|
||||
clearButton.className = 'button button-secondary table-filter-clear';
|
||||
clearButton.textContent =
|
||||
document.body.dataset.clearFiltersLabel || 'Filter zurücksetzen';
|
||||
clearButton.addEventListener('click', () => {
|
||||
filterDefinitions.forEach(definition => {
|
||||
definition.input.value = '';
|
||||
});
|
||||
applyFilters();
|
||||
if (usesServerFilters) submitServerFilters(true);
|
||||
|
||||
// Keep the persisted browser state in sync with the visible table.
|
||||
// Programmatic value changes do not trigger input/change automatically.
|
||||
window.dispatchEvent(new CustomEvent('asset-table-filters-cleared', {
|
||||
detail: { table }
|
||||
}));
|
||||
|
||||
filterDefinitions[0]?.input?.focus();
|
||||
});
|
||||
|
||||
status.append(icon, label, details, clearButton);
|
||||
|
||||
const target = filterStatusTarget();
|
||||
target.parentNode?.insertBefore(status, target);
|
||||
table._filterStatusElement = status;
|
||||
return status;
|
||||
}
|
||||
|
||||
function updateFilterPresentation(active, visibleRows, totalRows) {
|
||||
filterDefinitions.forEach(definition => {
|
||||
const hasValue = Boolean(definition.input.value.trim());
|
||||
definition.input.classList.toggle('filter-input-active', hasValue);
|
||||
definition.input.closest('th')?.classList.toggle('filter-cell-active', hasValue);
|
||||
});
|
||||
|
||||
const status = ensureFilterStatus();
|
||||
const label = status.querySelector('.table-filter-status-label');
|
||||
const details = status.querySelector('.table-filter-status-details');
|
||||
const activeCount = active.length;
|
||||
|
||||
status.hidden = activeCount === 0;
|
||||
status.classList.toggle('is-active', activeCount > 0);
|
||||
|
||||
if (activeCount > 0) {
|
||||
const activeTemplate =
|
||||
document.body.dataset.filtersActiveTemplate || '{count} Filter aktiv';
|
||||
const statusTemplate =
|
||||
document.body.dataset.filterStatusTemplate ||
|
||||
'Gefiltert: {visible} von {total} Datensätzen';
|
||||
|
||||
label.textContent = activeTemplate.replace('{count}', String(activeCount));
|
||||
details.textContent = statusTemplate
|
||||
.replace('{visible}', String(visibleRows))
|
||||
.replace('{total}', String(totalRows));
|
||||
}
|
||||
}
|
||||
|
||||
function cellForDefinition(row, definition) {
|
||||
if (definition.field) {
|
||||
return row.querySelector(`td[data-field="${CSS.escape(definition.field)}"]`);
|
||||
}
|
||||
return row.cells[definition.columnIndex] || null;
|
||||
}
|
||||
|
||||
function rebuildMissingCellKeys() {
|
||||
if (isAssetTable) return;
|
||||
|
||||
[...tbody.rows].forEach(row => {
|
||||
[...row.cells].forEach((cell, index) => {
|
||||
const header = headerRow.cells[index];
|
||||
if (header && !cell.dataset.field) {
|
||||
cell.dataset.field = header.dataset.field;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function numericFilterMatches(rawValue, rawTerm) {
|
||||
const value = Number(String(rawValue).trim().replace(',', '.'));
|
||||
if (!Number.isFinite(value)) return false;
|
||||
|
||||
const term = String(rawTerm || '').trim().toLocaleLowerCase();
|
||||
if (!term) return true;
|
||||
|
||||
const normalize = input => input.replace(',', '.').trim();
|
||||
let match = term.match(/^(<=|>=|<|>|=)\s*(-?\d+(?:[.,]\d+)?)$/);
|
||||
if (match) {
|
||||
const expected = Number(normalize(match[2]));
|
||||
if (match[1] === '<') return value < expected;
|
||||
if (match[1] === '<=') return value <= expected;
|
||||
if (match[1] === '>') return value > expected;
|
||||
if (match[1] === '>=') return value >= expected;
|
||||
return value === expected;
|
||||
}
|
||||
|
||||
match = term.match(/^(-?\d+(?:[.,]\d+)?)\s*(?:\.\.|-)\s*(-?\d+(?:[.,]\d+)?)$/);
|
||||
if (!match) {
|
||||
match = term.match(/^(?:zwischen|between)\s+(-?\d+(?:[.,]\d+)?)\s+(?:und|and)\s+(-?\d+(?:[.,]\d+)?)$/);
|
||||
}
|
||||
if (match) {
|
||||
const first = Number(normalize(match[1]));
|
||||
const second = Number(normalize(match[2]));
|
||||
const minimum = Math.min(first, second);
|
||||
const maximum = Math.max(first, second);
|
||||
return value >= minimum && value <= maximum;
|
||||
}
|
||||
|
||||
const exact = Number(normalize(term));
|
||||
return Number.isFinite(exact) ? value === exact : false;
|
||||
}
|
||||
|
||||
|
||||
function submitServerFilters(immediate = false) {
|
||||
if (!usesServerFilters) return;
|
||||
if (serverFilterTimer) window.clearTimeout(serverFilterTimer);
|
||||
|
||||
const navigate = () => {
|
||||
const target = new URL(
|
||||
table.dataset.serverFilterUrl || window.location.pathname,
|
||||
window.location.origin
|
||||
);
|
||||
const current = new URL(window.location.href);
|
||||
const limit = current.searchParams.get('job_limit');
|
||||
if (limit) target.searchParams.set('job_limit', limit);
|
||||
|
||||
filterDefinitions.forEach(definition => {
|
||||
if (!definition.serverName) return;
|
||||
const value = definition.input.value.trim();
|
||||
if (value) target.searchParams.set(`job_filter_${definition.serverName}`, value);
|
||||
});
|
||||
|
||||
try {
|
||||
window.sessionStorage.setItem(
|
||||
'assetmanager:software-jobs:last-server-filter',
|
||||
document.activeElement?.name || ''
|
||||
);
|
||||
} catch (_) {}
|
||||
window.location.assign(target.toString());
|
||||
};
|
||||
|
||||
if (immediate) navigate();
|
||||
else serverFilterTimer = window.setTimeout(navigate, 700);
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
rebuildMissingCellKeys();
|
||||
|
||||
const active = filterDefinitions
|
||||
.map(definition => ({
|
||||
definition,
|
||||
term: definition.input?.value.trim().toLocaleLowerCase() || ''
|
||||
}))
|
||||
.filter(item => item.term);
|
||||
|
||||
if (usesServerFilters) {
|
||||
const serverTotal = Number.parseInt(table.dataset.serverFilterTotal || '', 10);
|
||||
const total = Number.isFinite(serverTotal) ? serverTotal : tbody.rows.length;
|
||||
updateFilterPresentation(active, tbody.rows.length, total);
|
||||
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
|
||||
detail: {
|
||||
table,
|
||||
visible: tbody.rows.length,
|
||||
total,
|
||||
activeFilters: active.length
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let visibleRows = 0;
|
||||
|
||||
[...tbody.rows].forEach(row => {
|
||||
const hidden = active.some(({ definition, term }) => {
|
||||
const cell = cellForDefinition(row, definition);
|
||||
const value = textValue(cell);
|
||||
if (definition.numericFilter) {
|
||||
return !numericFilterMatches(value, term);
|
||||
}
|
||||
return !value.toLocaleLowerCase().includes(term);
|
||||
});
|
||||
|
||||
row.hidden = hidden;
|
||||
if (!hidden) visibleRows += 1;
|
||||
});
|
||||
|
||||
updateFilterPresentation(active, visibleRows, tbody.rows.length);
|
||||
|
||||
window.dispatchEvent(new CustomEvent('asset-table-filter-changed', {
|
||||
detail: {
|
||||
table,
|
||||
visible: visibleRows,
|
||||
total: tbody.rows.length,
|
||||
activeFilters: active.length
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
[...headerRow.cells].forEach((header, columnIndex) => {
|
||||
const field = header.dataset.field || '';
|
||||
const serverName = header.dataset.serverFilterName || '';
|
||||
|
||||
if (!header.dataset.noSort) {
|
||||
header.classList.add('sortable-column');
|
||||
header.title = header.dataset.sortTitle || 'Zum Sortieren anklicken';
|
||||
|
||||
header.addEventListener('click', event => {
|
||||
if (event.target.closest('input,button,a,select,textarea,label')) return;
|
||||
|
||||
const direction = header.dataset.sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
|
||||
[...headerRow.cells].forEach(item => {
|
||||
delete item.dataset.sortDirection;
|
||||
item.classList.remove('sort-asc', 'sort-desc');
|
||||
});
|
||||
|
||||
header.dataset.sortDirection = direction;
|
||||
header.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
const rows = [...tbody.rows];
|
||||
rows.sort((left, right) => {
|
||||
const definition = { field, columnIndex };
|
||||
const result = compareValues(
|
||||
textValue(cellForDefinition(left, definition)),
|
||||
textValue(cellForDefinition(right, definition))
|
||||
);
|
||||
return direction === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
rows.forEach(row => fragment.appendChild(row));
|
||||
tbody.appendChild(fragment);
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
const filterCell = document.createElement('th');
|
||||
if (field) filterCell.dataset.field = field;
|
||||
filterCell.dataset.columnIndex = String(columnIndex);
|
||||
|
||||
if (!header.dataset.noFilter && (!usesServerFilters || serverName)) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'search';
|
||||
input.placeholder = header.dataset.filterPlaceholder || 'Filtern …';
|
||||
input.setAttribute('aria-label', `Filter ${textValue(header)}`);
|
||||
input.autocomplete = 'off';
|
||||
if (usesServerFilters) {
|
||||
input.name = `job_filter_${serverName}`;
|
||||
input.value = header.dataset.serverFilterValue || '';
|
||||
input.addEventListener('input', () => {
|
||||
applyFilters();
|
||||
submitServerFilters(false);
|
||||
});
|
||||
input.addEventListener('search', () => {
|
||||
applyFilters();
|
||||
submitServerFilters(false);
|
||||
});
|
||||
input.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
submitServerFilters(true);
|
||||
});
|
||||
} else {
|
||||
input.addEventListener('input', applyFilters);
|
||||
input.addEventListener('search', applyFilters);
|
||||
}
|
||||
|
||||
filterCell.appendChild(input);
|
||||
filterDefinitions.push({
|
||||
field,
|
||||
columnIndex,
|
||||
input,
|
||||
serverName,
|
||||
numericFilter: header.dataset.numericFilter === '1'
|
||||
});
|
||||
}
|
||||
|
||||
filterRow.appendChild(filterCell);
|
||||
});
|
||||
|
||||
thead.appendChild(filterRow);
|
||||
|
||||
// Editable cells are always read live, so changed values immediately
|
||||
// participate in filtering without a stale cache.
|
||||
tbody.addEventListener('input', event => {
|
||||
if (event.target.matches('input,select,textarea')) applyFilters();
|
||||
});
|
||||
|
||||
tbody.addEventListener('change', event => {
|
||||
if (event.target.matches('input,select,textarea')) applyFilters();
|
||||
});
|
||||
|
||||
table.assetApplyFilters = applyFilters;
|
||||
table.assetRebuildFilterCache = rebuildMissingCellKeys;
|
||||
table.dataset.tableToolsReady = '1';
|
||||
ensureFilterStatus();
|
||||
|
||||
// Covers browser-restored values after reload/back navigation.
|
||||
requestAnimationFrame(() => {
|
||||
applyFilters();
|
||||
if (usesServerFilters) {
|
||||
try {
|
||||
const lastName = window.sessionStorage.getItem('assetmanager:software-jobs:last-server-filter');
|
||||
const lastInput = lastName
|
||||
? filterDefinitions.find(definition => definition.input.name === lastName)?.input
|
||||
: null;
|
||||
if (lastInput) {
|
||||
lastInput.focus();
|
||||
lastInput.setSelectionRange(lastInput.value.length, lastInput.value.length);
|
||||
}
|
||||
window.sessionStorage.removeItem('assetmanager:software-jobs:last-server-filter');
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
window.dispatchEvent(new CustomEvent('asset-table-structure-ready', {
|
||||
detail: { table }
|
||||
}));
|
||||
}
|
||||
|
||||
document.querySelectorAll('table.data-table').forEach(initTable);
|
||||
|
||||
window.addEventListener('asset-table-content-reloaded', event => {
|
||||
const table = event.detail?.table;
|
||||
if (table?.assetRebuildFilterCache) {
|
||||
table.assetRebuildFilterCache();
|
||||
table.assetApplyFilters?.();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,53 @@
|
||||
(() => {
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
|
||||
const body = document.body;
|
||||
const allowedPositions = new Set([
|
||||
'top-left', 'top-center', 'top-right',
|
||||
'bottom-left', 'bottom-center', 'bottom-right'
|
||||
]);
|
||||
const position = allowedPositions.has(body.dataset.toastPosition)
|
||||
? body.dataset.toastPosition
|
||||
: 'top-right';
|
||||
const seconds = Math.max(1, Math.min(120, Number.parseInt(body.dataset.toastDuration || '6', 10) || 6));
|
||||
container.classList.add(`toast-position-${position}`);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const entries = [
|
||||
['toast_error', 'error'],
|
||||
['toast_warning', 'warning'],
|
||||
['toast_success', 'success']
|
||||
];
|
||||
|
||||
function showToast(message, level) {
|
||||
if (!message) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${level}`;
|
||||
const symbol = level === 'success' ? '✓' : level === 'warning' ? '⚠' : '✕';
|
||||
toast.innerHTML = `<span class="toast-symbol">${symbol}</span><span class="toast-message"></span><button type="button" class="toast-close" aria-label="Schließen">×</button>`;
|
||||
toast.querySelector('.toast-message').textContent = message;
|
||||
toast.querySelector('.toast-close').addEventListener('click', () => toast.remove());
|
||||
container.appendChild(toast);
|
||||
window.setTimeout(() => {
|
||||
toast.classList.add('toast-hide');
|
||||
window.setTimeout(() => toast.remove(), 350);
|
||||
}, seconds * 1000);
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
entries.forEach(([key, level]) => {
|
||||
const values = params.getAll(key);
|
||||
values.forEach(value => showToast(value, level));
|
||||
if (values.length) {
|
||||
params.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
const query = params.toString();
|
||||
const cleanUrl = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`;
|
||||
window.history.replaceState({}, document.title, cleanUrl);
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user