Initialer Import des AssetManagers

This commit is contained in:
2026-08-02 21:28:50 +00:00
commit ed72ce8821
256 changed files with 26361 additions and 0 deletions
+238
View File
@@ -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();
}
})();
+112
View File
@@ -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);
})();
+41
View File
@@ -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]));
});
});
})();
+61
View File
@@ -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);
})();
+103
View File
@@ -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 };
})();
+113
View File
@@ -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');
})();
+15
View File
@@ -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();
});
});
})();
+132
View File
@@ -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');
})();
+74
View File
@@ -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});
})();
+58
View File
@@ -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};
})();
+74
View File
@@ -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();
}
})();
+103
View File
@@ -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();
}
})();
+503
View File
@@ -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?.();
}
});
})();
+53
View File
@@ -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);
}
})();