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
+512
View File
@@ -0,0 +1,512 @@
{% extends 'base.html' %}
{% block content %}
<div class="asset-page-loader" id="asset-page-loader" role="status" aria-live="polite">
<div class="asset-page-loader-card">
<span class="asset-page-spinner" aria-hidden="true"></span>
<strong>{{ t('assets.loading', 'Assets werden geladen …') }}</strong>
<span class="muted">{{ t('assets.loading_hint', 'Tabellenansicht und gespeicherte Einstellungen werden vorbereitet.') }}</span>
</div>
</div>
<div class="asset-list-page asset-list-initializing" id="asset-list-page" aria-busy="true">
<div class="toolbar">
<h1>{{ t('app.assets', 'Assets') }}{% if selected %}: {{ selected.name }}{% endif %}</h1>
<div class="toolbar-actions">
{% if is_admin and assets %}
<button type="button" class="button button-secondary" id="bulk-edit-button" data-base-label="✏ {{ t('assets.bulk_edit', 'Daten ändern')|e }}" disabled>✏ {{ t('assets.bulk_edit', 'Daten ändern') }}</button>
<button type="button" class="button button-danger" id="bulk-delete-button" data-base-label="🗑 {{ t('assets.delete_selected', 'Markierte löschen')|e }}" disabled>🗑 {{ t('assets.delete_selected', 'Markierte löschen') }}</button>
<button type="button" class="button button-secondary" id="merge-duplicates-button" disabled>⧉ {{ t('duplicates.merge', 'Duplikate zusammenführen') }}</button>
<div class="bulk-job-controls" aria-label="{{ t('jobs.bulk.controls_label')|e }}">
<label class="visually-hidden" for="bulk-job-type">{{ t('jobs.type') }}</label>
<select id="bulk-job-type" class="bulk-job-type" disabled>
<option value="">{{ t('jobs.select_type') }}</option>
<option value="software_inventory" data-platform="all">{{ t('jobs.type.software_inventory') }}</option>
{% for definition in job_definitions %}<option value="definition:{{ definition.id }}" data-platform="{{ definition.platform }}">{{ definition.name }}</option>{% endfor %}
</select>
<button type="button" class="button button-secondary" id="bulk-job-start-button" disabled>▶ {{ t('jobs.start') }}</button>
</div>
{% endif %}
<button type="button" class="button button-secondary" id="duplicate-search-button">⧉ {{ t('duplicates.search', 'Duplikate suchen') }}</button>
{% if is_admin %}<button type="button" class="button{% if not job_filter %} button-secondary{% endif %}" id="job-filter-button">⌁ {{ t('assets.job_filter.button', 'Jobstatus filtern') }}{% if job_filter %} ({{ total_assets }}){% endif %}</button>{% endif %}
<button type="button" class="button button-secondary" id="column-settings-button">▦ {{ t('assets.columns', 'Spalten') }}</button>
<button type="button" class="button button-secondary" id="reload-assets-button" data-loading-label="⏳ {{ t('common.loading', 'Lade...')|e }}">🔄 {{ t('assets.reload', 'Inhalt neu laden') }}</button>
<a class="button button-secondary" href="/settings/import-export">⇅ {{ t('settings.import_export', 'Import / Export') }}</a>
{% if is_admin %}
<a class="button" href="/assets/new{% if selected %}?category_id={{ selected.id }}{% endif %}"> {{ t('assets.create', 'Asset anlegen') }}</a>{% endif %}
</div>
</div>
<div class="filters"><a href="/assets">{{ t('common.all', 'Alle') }}</a>{% for c in categories %}<a href="/assets?category_id={{ c.id }}">{{ c.name }}</a>{% endfor %}</div>
{% if job_filter %}
<div class="notice job-filter-summary">
<strong>{{ t('assets.job_filter.active', 'Aktiver Jobfilter') }}:</strong>
{% for spec in job_filter.specs %}{% if spec.label_key %}{{ t(spec.label_key, spec.label) }}{% else %}{{ spec.label }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
· {{ t('assets.job_filter.excluded_summary', 'Ausgeschlossen') }}:
{% if job_filter.excluded_statuses %}
{% for status in job_filter.excluded_statuses %}{{ t('software.status.' ~ status, status) }}{% if not loop.last %}, {% endif %}{% endfor %}
{% else %}
{{ t('assets.job_filter.excluded_none', 'keine Status') }}
{% endif %}
{% if job_filter.revision_mode == 'current' %}· {{ t('assets.job_filter.current_revision', 'nur aktuelle Revision') }}{% endif %}
<span class="muted">· {{ t('assets.total', '{count} Assets').format(count=total_assets) }}</span>
<a class="button button-secondary button-small" href="/assets{% if selected %}?category_id={{ selected.id }}{% endif %}">{{ t('assets.job_filter.clear', 'Jobfilter entfernen') }}</a>
{% if is_admin and assets and job_filter.startable_specs %}
{% if job_filter.startable_specs|length > 1 %}
<label class="job-filter-start-choice">{{ t('assets.job_filter.start_job', 'Auszuführender Job') }}
<select id="start-filtered-job-selection">
{% for spec in job_filter.startable_specs %}<option value="{{ spec.selection }}">{% if spec.label_key %}{{ t(spec.label_key, spec.label) }}{% else %}{{ spec.label }}{% endif %}</option>{% endfor %}
</select>
</label>
{% endif %}
<button type="button" class="button button-small" id="start-filtered-job-button">▶ {{ t('assets.job_filter.start', 'Job für gefilterte Assets starten') }} ({{ total_assets }})</button>
{% endif %}
</div>
{% endif %}
{% if selected %}<p class="table-hint">{{ t('assets.column_order_hint') }}</p>{% endif %}
{% if is_admin %}
<form id="bulk-edit-form" class="hidden-utility-form" method="post" action="/assets/bulk-edit">{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}<span id="bulk-edit-ids"></span></form>
<form id="bulk-delete-form" class="hidden-utility-form" method="post" action="/assets/bulk-delete" onsubmit="return confirm({{ t('assets.bulk_delete_confirm')|tojson }});">{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}<span id="bulk-delete-ids"></span></form>
<form id="merge-duplicates-form" class="hidden-utility-form" method="post" action="/assets/merge"><span id="merge-duplicate-ids"></span></form>
<form id="bulk-job-form" class="hidden-utility-form" method="post" action="/jobs/bulk-create">
<input type="hidden" name="job_type" id="bulk-job-type-value">
<input type="hidden" name="definition_id" id="bulk-job-definition-value">
<span id="bulk-job-ids"></span>
</form>
{% if job_filter %}
<form id="start-filtered-job-form" class="hidden-utility-form" method="post" action="/assets/job-filter/start">
{% for selection in job_filter.selections %}<input type="hidden" name="job_filter" value="{{ selection }}">{% endfor %}
<input type="hidden" name="job_filter_start" id="start-filtered-job-value" value="{{ job_filter.startable_specs[0].selection if job_filter.startable_specs else job_filter.selections[0] }}">
<input type="hidden" name="job_filter_revision" value="{{ job_filter.revision_mode }}">
{% for status in job_filter.excluded_statuses %}<input type="hidden" name="job_filter_exclude" value="{{ status }}">{% endfor %}
{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}
</form>
{% endif %}
{% endif %}
<div class="table-scrollbar-top" id="asset-table-scroll-top" aria-hidden="true"><div></div></div>
<div class="table-scroll sticky-table" id="asset-table-scroll">
<table class="data-table asset-table-fixed" id="asset-table" data-storage-key="asset-columns-{{ selected.id if selected else 'all' }}" data-fixed-column-width="{% if is_admin %}224{% else %}180{% endif %}">
<thead><tr>
{% if is_admin %}<th class="selection-column" data-no-sort="1" data-no-filter="1"><input type="checkbox" id="select-all-assets" aria-label="{{ t('assets.select_all') }}"></th>{% endif %}
{% set has_name = fields|selectattr('field_name', 'equalto', 'name')|list|length > 0 %}
{% if not has_name %}<th draggable="true" data-field="name" data-column-width="220" data-label="{{ t('field.name', 'Bezeichnung')|e }}" style="width:220px;min-width:220px;max-width:220px">{{ t('field.name', 'Bezeichnung') }}</th>{% endif %}
{% if not selected %}<th draggable="true" data-field="category" data-column-width="180" data-label="{{ t('common.category', 'Kategorie')|e }}" style="width:180px;min-width:180px;max-width:180px">{{ t('common.category', 'Kategorie') }}</th>{% endif %}
{% for f in fields %}{% set list_width = f.definition.list_width if f.definition and f.definition.list_width else (220 if f.field_name == 'name' else 180) %}<th draggable="true" data-field="{{ f.field_name }}" data-column-width="{{ list_width }}" data-label="{{ f.label|e }}" style="width:{{ list_width }}px;min-width:{{ list_width }}px;max-width:{{ list_width }}px">{{ f.label }}</th>{% endfor %}
{% if job_filter %}
<th data-field="job_filter_status" data-label="{{ t('assets.job_filter.status', 'Jobstatus')|e }}" style="width:180px;min-width:180px;max-width:180px">{{ t('assets.job_filter.status', 'Jobstatus') }}</th>
<th data-field="job_filter_last_execution" data-label="{{ t('assets.job_filter.last_execution', 'Letzte Ausführung')|e }}" style="width:175px;min-width:175px;max-width:175px">{{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }}</th>
<th data-field="job_filter_last_success" data-label="{{ t('assets.job_filter.last_success', 'Letzter Erfolg')|e }}" style="width:175px;min-width:175px;max-width:175px">{{ t('assets.job_filter.last_success', 'Letzter Erfolg') }}</th>
<th data-field="job_filter_revision" data-label="{{ t('assets.job_filter.revision', 'Revision')|e }}" style="width:100px;min-width:100px;max-width:100px">{{ t('assets.job_filter.revision', 'Revision') }}</th>
{% endif %}
<th data-field="mesh_presence" data-label="{{ t('presence.title')|e }}" style="width:125px;min-width:125px;max-width:125px">{{ t('presence.title') }}</th>
<th data-no-sort="1" data-no-filter="1" class="actions-column">{{ t('common.actions', 'Aktionen') }}</th>
</tr></thead>
<tbody>
{% for a in assets %}<tr data-platform="{{ asset_platforms.get(a.id, 'unknown') }}">
{% if is_admin %}<td class="selection-column"><input type="checkbox" class="asset-select" value="{{ a.id }}" aria-label="{{ t('assets.select_named', 'Asset {name} markieren').format(name=a.name)|e }}"></td>{% endif %}
{% if not has_name %}<td data-field="name"><a href="/assets/{{ a.id }}">{{ a.name }}</a></td>{% endif %}
{% if not selected %}<td data-field="category">{{ a.category.name }}</td>{% endif %}
{% for f in fields %}{% set list_width = f.definition.list_width if f.definition and f.definition.list_width else (220 if f.field_name == 'name' else 180) %}<td data-field="{{ f.field_name }}" class="multiline" style="width:{{ list_width }}px;min-width:{{ list_width }}px;max-width:{{ list_width }}px">{% if f.field_name == 'name' %}<a href="/assets/{{ a.id }}">{{ a.name }}</a>{% else %}{{ a|attr(f.field_name) or '' }}{% endif %}</td>{% endfor %}
{% if job_filter %}{% set job_state = job_state_by_asset.get(a.id) %}
<td data-field="job_filter_status" style="width:180px;min-width:180px;max-width:180px"><span class="job-status job-status-{{ job_state.status }}">{{ t('software.status.' ~ job_state.status, t('assets.job_filter.never_run', 'Noch nie ausgeführt') if job_state.status == 'never_run' else job_state.status) }}</span>{% if job_filter.specs|length > 1 and job_state.job_label %}<small class="muted job-filter-source">{{ job_state.job_label }}</small>{% endif %}</td>
<td data-field="job_filter_last_execution" style="width:175px;min-width:175px;max-width:175px">{% if job_state.last_execution %}<time class="local-time" datetime="{{ utc_iso(job_state.last_execution) }}" data-utc="{{ utc_iso(job_state.last_execution) }}">{{ job_state.last_execution }}</time>{% else %}<span class="muted"></span>{% endif %}</td>
<td data-field="job_filter_last_success" style="width:175px;min-width:175px;max-width:175px">{% if job_state.last_success %}<time class="local-time" datetime="{{ utc_iso(job_state.last_success) }}" data-utc="{{ utc_iso(job_state.last_success) }}">{{ job_state.last_success }}</time>{% else %}<span class="muted"></span>{% endif %}</td>
<td data-field="job_filter_revision" style="width:100px;min-width:100px;max-width:100px">{% if job_state.definition_revision %}{{ job_state.definition_revision }}{% else %}<span class="muted"></span>{% endif %}</td>
{% endif %}
<td data-field="mesh_presence" style="width:125px;min-width:125px;max-width:125px">
{% if a.mesh_node_id %}
<span
class="presence-badge presence-{{ a.mesh_online_state or 'unknown' }}"
data-presence-asset="{{ a.id }}"
data-label-online="{{ t('presence.online') }}"
data-label-offline="{{ t('presence.offline') }}"
data-label-unknown="{{ t('presence.unknown') }}"
data-last-seen-label="{{ t('presence.last_seen') }}"
title="{{ t('presence.' ~ (a.mesh_online_state or 'unknown')) }} · {{ t('presence.last_seen') }}: {% if a.mesh_last_seen %}{{ a.mesh_last_seen }}{% else %}{% endif %}"
><span class="presence-dot" aria-hidden="true"></span><span data-presence-label>{{ t('presence.' ~ (a.mesh_online_state or 'unknown')) }}</span></span>
{% else %}
<span class="muted"></span>
{% endif %}
</td>
<td class="row-actions">{% if is_admin %}
{% if issue_status_name and a.status == issue_status_name %}<form class="inline-form" method="post" action="/assets/{{a.id}}/return"><input type="hidden" name="return_to" value="list"><button class="icon-button button-return" title="{{ t('assets.return', 'Zurücknehmen') }}"></button></form>{% else %}<button type="button" class="icon-button button-issue" data-issue-action="/assets/{{a.id}}/issue" data-return-to="list" title="{{ t('assets.issue', 'Ausgeben') }}"></button>{% endif %}
<a class="icon-button button-edit" href="/assets/{{a.id}}/edit" title="{{ t('common.edit', 'Bearbeiten') }}" aria-label="{{ t('common.edit', 'Bearbeiten') }}">&#9998;</a>
<a class="icon-button button-duplicate" href="/assets/{{a.id}}/duplicate" title="{{ t('common.duplicate', 'Duplizieren') }}"></a><form class="inline-form" method="post" action="/assets/{{a.id}}/delete" onsubmit="return confirm({{ t('assets.delete_confirm').format(name=a.name)|tojson }});"><button class="icon-button button-danger" title="{{ t('common.delete', 'Löschen') }}">🗑</button></form>{% else %}<span class="muted">{{ t('common.read_only', 'Nur lesen') }}</span>{% endif %}</td>
</tr>{% else %}<tr><td colspan="40" class="empty-state">{{ t('assets.none', 'Keine Assets vorhanden.') }}</td></tr>{% endfor %}
</tbody>
</table></div>
<p class="muted asset-total">{{ t('assets.total', '{count} Assets').format(count=total_assets) }}</p>
{% if is_admin %}
<dialog id="job-filter-dialog" class="duplicate-search-dialog">
<form method="get" action="/assets" id="job-filter-form">
<div class="dialog-heading">
<h2>{{ t('assets.job_filter.title', 'Assets nach Jobstatus filtern') }}</h2>
<button type="button" onclick="this.closest('dialog').close()" class="icon-button button-secondary" aria-label="{{ t('common.close', 'Schließen') }}">×</button>
</div>
<p class="muted">{{ t('assets.job_filter.help', 'Die Jobinformationen werden nur bei aktivem Filter geladen. Die normale Assetliste bleibt unverändert schnell.') }}</p>
{% if selected %}<input type="hidden" name="category_id" value="{{ selected.id }}">{% endif %}
<fieldset class="job-filter-status-fieldset">
<legend>{{ t('assets.job_filter.jobs', 'Jobs') }}</legend>
<p class="muted">{{ t('assets.job_filter.jobs_help', 'Mehrere Jobs werden als alternative Ausführungen zusammengefasst. Maßgeblich ist der zuletzt ausgeführte ausgewählte Job.') }}</p>
<div class="job-filter-job-options">
<label class="checkbox-label">
<input type="checkbox" class="checkbox job-filter-job-checkbox" name="job_filter" value="software_inventory"{% if job_filter and 'software_inventory' in job_filter.selections %} checked{% endif %}>
{{ t('jobs.type.software_inventory', 'Softwareinventur') }}
</label>
{% for definition in job_filter_definitions %}
<label class="checkbox-label">
<input type="checkbox" class="checkbox job-filter-job-checkbox" name="job_filter" value="definition:{{ definition.id }}" data-revision="{{ definition.revision }}"{% if job_filter and 'definition:' ~ definition.id in job_filter.selections %} checked{% endif %}>
{{ definition.name }}
</label>
{% endfor %}
</div>
</fieldset>
<fieldset class="job-filter-status-fieldset">
<legend>{{ t('assets.job_filter.excluded_statuses', 'Auszuschließende Status') }}</legend>
<p class="muted">{{ t('assets.job_filter.excluded_help', 'Markierte Status werden nicht in der Assetliste angezeigt. Ohne Markierung werden alle Assets angezeigt.') }}</p>
<div class="job-filter-status-options">
{% for status in job_filter_statuses %}
<label class="checkbox-label">
<input type="checkbox" class="checkbox job-filter-status-checkbox" name="job_filter_exclude" value="{{ status }}"{% if job_filter and status in job_filter.excluded_statuses %} checked{% endif %}>
{{ t('software.status.' ~ status, status) }}
</label>
{% endfor %}
</div>
<div class="form-actions job-filter-status-actions">
<button type="button" class="button button-secondary button-small" id="job-filter-exclude-all">{{ t('assets.job_filter.exclude_all', 'Alle markieren') }}</button>
<button type="button" class="button button-secondary button-small" id="job-filter-exclude-none">{{ t('assets.job_filter.exclude_none', 'Auswahl aufheben') }}</button>
</div>
</fieldset>
<label class="checkbox-label" id="job-filter-revision-label">
<input type="checkbox" class="checkbox" id="job-filter-current-revision"{% if job_filter and job_filter.revision_mode == 'current' %} checked{% endif %}>
{{ t('assets.job_filter.current_revision', 'nur aktuelle Revision') }}
<input type="hidden" name="job_filter_revision" id="job-filter-revision" value="{{ job_filter.revision_mode if job_filter else 'all' }}">
</label>
<div class="form-actions">
<a class="button button-secondary" href="/assets{% if selected %}?category_id={{ selected.id }}{% endif %}">{{ t('assets.job_filter.clear', 'Jobfilter entfernen') }}</a>
<button type="submit" class="button">{{ t('assets.job_filter.apply', 'Filter anwenden') }}</button>
</div>
</form>
</dialog>
{% endif %}
<dialog id="duplicate-search-dialog" class="duplicate-search-dialog">
<form method="dialog">
<div class="dialog-heading">
<h2>{{ t('duplicates.search_title') }}</h2>
<button type="submit" class="icon-button button-secondary" aria-label="{{ t('common.close', 'Schließen') }}">×</button>
</div>
<p class="muted">{{ t('duplicates.visible_only_help') }}</p>
<label>{{ t('common.field') }}
<select id="duplicate-field-select"></select>
</label>
<label class="checkbox-label">
<input type="checkbox" class="checkbox" id="duplicate-ignore-empty" checked>
{{ t('duplicates.ignore_empty') }}
</label>
<label class="checkbox-label">
<input type="checkbox" class="checkbox" id="duplicate-case-insensitive" checked>
{{ t('duplicates.ignore_case') }}
</label>
<div id="duplicate-search-summary" class="notice" hidden></div>
<div class="form-actions">
<button type="button" class="button button-secondary" id="duplicate-clear-button">{{ t('duplicates.clear_filter') }}</button>
<button type="button" class="button" id="duplicate-apply-button">{{ t('duplicates.show') }}</button>
<button type="submit" class="button button-secondary">{{ t('common.close', 'Schließen') }}</button>
</div>
</form>
</dialog>
<dialog id="column-settings-dialog" class="column-settings-dialog">
<form method="dialog">
<div class="dialog-heading">
<h2>{{ t('assets.columns', 'Spalten') }}</h2>
<button type="submit" class="icon-button button-secondary" aria-label="{{ t('common.close', 'Schließen') }}">×</button>
</div>
<p class="muted">{{ t('assets.columns_hint', 'Spalten ein- oder ausblenden. Die Auswahl wird in diesem Browser gespeichert.') }}</p>
<div id="column-settings-list" class="column-settings-list"></div>
<div class="form-actions">
<button type="button" class="button button-secondary" id="show-all-columns">{{ t('assets.show_all_columns', 'Alle einblenden') }}</button>
<button type="button" class="button button-secondary" id="reset-columns">{{ t('assets.reset_columns', 'Zurücksetzen') }}</button>
<button type="submit" class="button">{{ t('common.close', 'Schließen') }}</button>
</div>
</form>
</dialog>
<div id="column-context-menu" class="column-context-menu" hidden>
<button type="button" id="context-hide-column">{{ t('assets.hide_column', 'Spalte ausblenden') }}</button>
<button type="button" id="context-open-columns">{{ t('assets.manage_columns', 'Spalten verwalten…') }}</button>
</div>
{% if is_admin %}
<script>
(() => {
const dialog = document.getElementById('job-filter-dialog');
const openButton = document.getElementById('job-filter-button');
const revisionLabel = document.getElementById('job-filter-revision-label');
const revisionCheckbox = document.getElementById('job-filter-current-revision');
const revisionValue = document.getElementById('job-filter-revision');
const form = document.getElementById('job-filter-form');
const statusCheckboxes = () => [...document.querySelectorAll('.job-filter-status-checkbox')];
const jobCheckboxes = () => [...document.querySelectorAll('.job-filter-job-checkbox')];
const refreshFields = () => {
const hasDefinition = jobCheckboxes().some(box => box.checked && String(box.value).startsWith('definition:'));
if (revisionLabel) revisionLabel.hidden = !hasDefinition;
if (revisionValue) revisionValue.value = revisionCheckbox?.checked && hasDefinition ? 'current' : 'all';
};
openButton?.addEventListener('click', () => { refreshFields(); dialog?.showModal(); });
jobCheckboxes().forEach(box => box.addEventListener('change', refreshFields));
revisionCheckbox?.addEventListener('change', refreshFields);
form?.addEventListener('submit', event => {
refreshFields();
if (!jobCheckboxes().some(box => box.checked)) {
event.preventDefault();
window.alert({{ t('assets.job_filter.select_at_least_one', 'Bitte mindestens einen Job auswählen.')|tojson }});
}
});
document.getElementById('job-filter-exclude-all')?.addEventListener('click', () => statusCheckboxes().forEach(box => { box.checked = true; }));
document.getElementById('job-filter-exclude-none')?.addEventListener('click', () => statusCheckboxes().forEach(box => { box.checked = false; }));
refreshFields();
const startButton = document.getElementById('start-filtered-job-button');
const startForm = document.getElementById('start-filtered-job-form');
const startSelection = document.getElementById('start-filtered-job-selection');
const startValue = document.getElementById('start-filtered-job-value');
startButton?.addEventListener('click', () => {
if (startValue && startSelection) startValue.value = startSelection.value;
const template = {{ t('assets.job_filter.start_confirm', 'Den ausgewählten Job für alle {count} gefilterten Assets erstellen und starten?')|tojson }};
const message = template.replace('{count}', {{ total_assets|tojson }});
if (window.confirm(message)) startForm?.requestSubmit();
});
})();
</script>
{% endif %}
<script>
(() => {
const table = document.getElementById('asset-table');
if (!table) return;
const key = table.dataset.storageKey;
const movableHeaders = () => [...table.querySelectorAll('thead tr:first-child th[data-field]')];
const fields = () => movableHeaders().map(th => th.dataset.field);
function applyOrder(order) {
if (!Array.isArray(order)) return;
const current = fields();
const valid = order.filter(f => current.includes(f));
current.forEach(f => { if (!valid.includes(f)) valid.push(f); });
const headerRow = table.tHead.rows[0];
const headerActions = headerRow.lastElementChild;
const headerFragment = document.createDocumentFragment();
valid.forEach(field => {
const header = headerRow.querySelector(`th[data-field="${CSS.escape(field)}"]`);
if (header) headerFragment.appendChild(header);
});
headerRow.insertBefore(headerFragment, headerActions);
[...table.tBodies[0].rows].forEach(row => {
const actions = row.lastElementChild;
const fragment = document.createDocumentFragment();
valid.forEach(field => {
const cell = row.querySelector(`td[data-field="${CSS.escape(field)}"]`);
if (cell) fragment.appendChild(cell);
});
row.insertBefore(fragment, actions);
});
}
try { applyOrder(JSON.parse(window.AssetBrowserState?.get(key) || 'null')); } catch (_) {}
table.dataset.columnOrderReady = '1';
window.dispatchEvent(new Event('asset-table-column-order-ready'));
let dragged = null;
movableHeaders().forEach(th => {
th.addEventListener('dragstart', () => { dragged = th.dataset.field; th.classList.add('dragging'); });
th.addEventListener('dragend', () => { th.classList.remove('dragging'); dragged = null; });
th.addEventListener('dragover', e => e.preventDefault());
th.addEventListener('drop', e => {
e.preventDefault();
const target = th.dataset.field;
if (!dragged || dragged === target) return;
const order = fields();
order.splice(order.indexOf(dragged), 1);
order.splice(order.indexOf(target), 0, dragged);
applyOrder(order);
window.AssetBrowserState?.set(key, JSON.stringify(order));
window.dispatchEvent(new Event('asset-table-columns-changed'));
});
});
})();
</script>
{% if is_admin %}
<script>
(() => {
const table = document.getElementById('asset-table');
if (!table) return;
const boxes = () => [...table.querySelectorAll('.asset-select')];
const all = document.getElementById('select-all-assets');
const editButton = document.getElementById('bulk-edit-button');
const deleteButton = document.getElementById('bulk-delete-button');
const mergeButton = document.getElementById('merge-duplicates-button');
const jobTypeSelect = document.getElementById('bulk-job-type');
const jobStartButton = document.getElementById('bulk-job-start-button');
const isVisible = box => {
const row = box.closest('tr');
if (!row || row.hidden || row.classList.contains('hidden')) return false;
return true;
};
const visibleBoxes = () => boxes().filter(isVisible);
const selected = () => visibleBoxes().filter(box => box.checked).map(box => box.value);
function selectedPlatforms() {
return [...new Set(visibleBoxes().filter(box => box.checked).map(box => box.closest('tr')?.dataset.platform || 'unknown'))];
}
function filterJobDefinitions() {
if (!jobTypeSelect) return;
const platforms = selectedPlatforms();
const singlePlatform = platforms.length === 1 && platforms[0] !== 'unknown' ? platforms[0] : null;
[...jobTypeSelect.options].forEach((option, index) => {
if (index === 0) { option.hidden = false; option.disabled = false; return; }
const platform = option.dataset.platform || 'all';
const compatible = !singlePlatform || platform === 'all' || platform === singlePlatform;
option.hidden = !compatible;
option.disabled = !compatible;
});
if (jobTypeSelect.selectedOptions[0]?.disabled) jobTypeSelect.value = '';
}
function refresh() {
// Hidden rows must never remain part of a bulk operation after filtering.
boxes().filter(box => !isVisible(box)).forEach(box => { box.checked = false; });
const visible = visibleBoxes();
const count = selected().length;
if (editButton) { editButton.disabled = count === 0; editButton.textContent = count ? `✏ {{ t('assets.bulk_edit', 'Daten ändern') }} (${count})` : '✏ {{ t('assets.bulk_edit', 'Daten ändern') }}'; }
if (deleteButton) { deleteButton.disabled = count === 0; deleteButton.textContent = count ? `🗑 {{ t('assets.delete_selected', 'Markierte löschen') }} (${count})` : '🗑 {{ t('assets.delete_selected', 'Markierte löschen') }}'; }
if (mergeButton) {
mergeButton.disabled = count < 2;
mergeButton.textContent = count >= 2 ? `⧉ {{ t('duplicates.merge') }} (${count})` : '⧉ {{ t('duplicates.merge') }}';
}
if (jobTypeSelect) { jobTypeSelect.disabled = count === 0; filterJobDefinitions(); }
if (jobStartButton) {
jobStartButton.disabled = count === 0 || !jobTypeSelect?.value;
jobStartButton.textContent = count
? `▶ {{ t('jobs.start') }} (${count})`
: '▶ {{ t('jobs.start') }}';
}
if (all) { all.checked = visible.length > 0 && count === visible.length; all.indeterminate = count > 0 && count < visible.length; }
}
function submit(formId, containerId) {
const form = document.getElementById(formId);
const container = document.getElementById(containerId);
container.innerHTML = '';
selected().forEach(id => { const input = document.createElement('input'); input.type='hidden'; input.name='asset_ids'; input.value=id; container.appendChild(input); });
form.requestSubmit();
}
table.addEventListener('change', event => {
if (event.target.matches('.asset-select')) refresh();
});
all?.addEventListener('change', () => { visibleBoxes().forEach(box => box.checked = all.checked); boxes().filter(box => !isVisible(box)).forEach(box => box.checked = false); refresh(); });
editButton?.addEventListener('click', () => submit('bulk-edit-form', 'bulk-edit-ids'));
deleteButton?.addEventListener('click', () => submit('bulk-delete-form', 'bulk-delete-ids'));
mergeButton?.addEventListener('click', () => submit('merge-duplicates-form', 'merge-duplicate-ids'));
jobTypeSelect?.addEventListener('change', refresh);
jobStartButton?.addEventListener('click', () => {
const count = selected().length;
const jobType = jobTypeSelect?.value || '';
if (!count || !jobType) return;
const label = jobTypeSelect?.selectedOptions[0]?.textContent?.trim() || jobType;
const template = {{ t('jobs.bulk.confirm', 'Für {count} markierte Assets jeweils den Job „{job}“ erstellen und starten?')|tojson }};
const message = template.replace('{count}', String(count)).replace('{job}', label);
if (!window.confirm(message)) return;
const jobTypeValue = document.getElementById('bulk-job-type-value');
const definitionValue = document.getElementById('bulk-job-definition-value');
if (jobType.startsWith('definition:')) {
if (jobTypeValue) jobTypeValue.value = 'job_definition';
if (definitionValue) definitionValue.value = jobType.split(':', 2)[1];
} else {
if (jobTypeValue) jobTypeValue.value = jobType;
if (definitionValue) definitionValue.value = '';
}
submit('bulk-job-form', 'bulk-job-ids');
});
// Table filters may hide rows by style, class or hidden attribute. Observe all
// such changes and keep the selection restricted to currently displayed rows.
const observer = new MutationObserver(refresh);
const observeRows = () => table.querySelectorAll('tbody tr').forEach(row => observer.observe(row, {attributes:true, attributeFilter:['style','class','hidden']}));
observeRows();
window.addEventListener('asset-table-content-reloaded', () => {
all.checked = false;
all.indeterminate = false;
observeRows();
refresh();
});
table.addEventListener('input', () => setTimeout(refresh, 0));
table.addEventListener('change', () => setTimeout(refresh, 0));
window.addEventListener('asset-table-filter-changed', refresh);
refresh();
})();
</script>
{% endif %}
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ now().strftime('%Y-%m-%d') if now is defined else '' }}" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js"></script>
<script>
(() => {
const btn = document.getElementById('reload-assets-button');
if (!btn) return;
btn.addEventListener('click', async () => {
const tbody = document.querySelector('#asset-table tbody');
if (!tbody) return;
btn.disabled = true;
const old = btn.textContent;
btn.textContent = btn.dataset.loadingLabel || '⏳ {{ t('common.loading') }}';
try {
const response = await fetch(window.location.href, {
headers: {'X-Requested-With': 'XMLHttpRequest'}
});
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const newBody = doc.querySelector('#asset-table tbody');
const count = doc.querySelector('.asset-total');
if (newBody) {
const table = document.getElementById('asset-table');
if (table) {
const orderedFields = [...table.tHead.rows[0].cells]
.map(cell => cell.dataset.field)
.filter(Boolean);
[...newBody.rows].forEach(row => {
const actions = row.lastElementChild;
const fragment = document.createDocumentFragment();
orderedFields.forEach(field => {
const cell = row.querySelector(`td[data-field="${CSS.escape(field)}"]`);
if (cell) fragment.appendChild(cell);
});
if (actions) row.insertBefore(fragment, actions);
});
}
// Keep the existing tbody element. Table filters, sorting and delegated
// event handlers are bound to it and would otherwise still reference the
// detached old body after an AJAX reload.
tbody.replaceChildren(...newBody.childNodes);
table?.assetRebuildFilterCache?.();
window.dispatchEvent(new CustomEvent('asset-table-content-reloaded', {
detail: { table }
}));
window.dispatchEvent(new Event('asset-table-columns-changed'));
}
if (count) {
const current = document.querySelector('.asset-total');
if (current) current.replaceWith(count);
}
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
})();
</script>
</div>
<script>requestAnimationFrame(()=>requestAnimationFrame(()=>document.getElementById('asset-list-page')?.classList.remove('asset-list-initializing')));</script>
{% endblock %}