Files

513 lines
34 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}