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
+34
View File
@@ -0,0 +1,34 @@
<section class="chart-card">
<div class="chart-card-head">
<div><h3>{{ item.chart.name }}</h3>{% if item.chart.description %}<p>{{ item.chart.description }}</p>{% endif %}</div>
<span class="chart-total">{{ item.total }} Geräte</span>
</div>
{% if not item['items'] %}
<div class="chart-empty">Keine passenden Daten vorhanden.</div>
{% elif item.chart.chart_type in ['donut', 'pie'] %}
<div class="donut-layout">
<div class="donut-chart {% if item.chart.chart_type == 'pie' %}pie-chart{% endif %}" style="background: conic-gradient({{ item.donut_gradient }})"><span>{{ item.total }}</span></div>
<div class="chart-legend">
{% for label, value in item['items'] %}
<div><i style="--legend-color:var(--chart-color-{{ loop.index0 % 12 }})"></i><span>{{ label }}</span><strong>{{ value }}</strong></div>
{% endfor %}
</div>
</div>
{% elif item.chart.chart_type == 'stacked_bar' %}
<div class="stack-legend">{% for stack in item.stack_values %}<span><i style="--legend-color:var(--chart-color-{{ loop.index0 % 12 }})"></i>{{ stack }}</span>{% endfor %}</div>
<div class="bar-chart stacked-chart">
{% for label, values in item['items'] %}
{% set row_total = values.values()|sum %}
<div class="bar-row"><div class="bar-label" title="{{ label }}">{{ label }}</div><div class="bar-track">
{% for stack in item.stack_values %}{% set count = values.get(stack, 0) %}{% if count %}<div class="bar-segment" title="{{ stack }}: {{ count }}" style="width:{{ (count / item.maximum * 100)|round(2) }}%;--segment-color:var(--chart-color-{{ loop.index0 % 12 }})"><span>{{ count }}</span></div>{% endif %}{% endfor %}
</div><strong class="bar-value">{{ row_total }}</strong></div>
{% endfor %}
</div>
{% else %}
<div class="bar-chart">
{% for label, value in item['items'] %}
<div class="bar-row"><div class="bar-label" title="{{ label }}">{{ label }}</div><div class="bar-track"><div class="bar-segment" style="width:{{ (value / item.maximum * 100)|round(2) }}%;--segment-color:var(--chart-color-{{ loop.index0 % 12 }})"><span>{{ value }}</span></div></div><strong class="bar-value">{{ value }}</strong></div>
{% endfor %}
</div>
{% endif %}
</section>
+302
View File
@@ -0,0 +1,302 @@
{% extends "base.html" %}
{% macro inventory_label(label) -%}
{%- set text = label|string -%}
{{- t(text, text) if text.startswith('inventory.') else text -}}
{%- endmacro %}
{% macro inventory_icon(label) -%}
{%- set key = (label|string)|lower -%}
{%- if 'system_overview' in key or 'window' in key or key in ['computer', 'system', 'hardware'] -%}🖥️
{%- elif 'processor' in key or 'cpu' in key -%}🧠
{%- elif 'memory' in key or 'ram' in key -%}🧩
{%- elif 'storage' in key or 'drive' in key or 'disk' in key or 'volume' in key or 'partition' in key -%}💾
{%- elif 'graphic' in key or 'gpu' in key or 'video' in key or 'display' in key -%}🎨
{%- elif 'monitor' in key or 'screen' in key -%}🖥️
{%- elif 'network' in key or 'ethernet' in key or 'wifi' in key or 'adapter' in key or 'wlan' in key -%}🌐
{%- elif 'battery' in key -%}🔋
{%- elif 'bios' in key or 'uefi' in key or 'firmware' in key -%}⚙️
{%- elif 'board' in key or 'motherboard' in key or 'mainboard' in key -%}🔌
{%- elif 'audio' in key or 'sound' in key -%}🔊
{%- elif 'usb' in key -%}🔗
{%- elif 'printer' in key -%}🖨️
{%- elif 'software' in key or 'application' in key -%}📦
{%- elif 'os_' in key or 'operating' in key -%}🪟
{%- elif 'antivirus' in key or 'security' in key or 'tpm' in key -%}🛡️
{%- elif 'mesh' in key or 'agent' in key or 'connection' in key -%}🔗
{%- else -%}📁
{%- endif -%}
{%- endmacro %}
{% macro inventory_node(label, value, level=0, path='root') %}
{% set node_path = path ~ '/' ~ (label|string)|replace(' ', '_')|replace('/', '_') %}
{% if value is mapping %}
<details class="inventory-node" data-inventory-tree-path="{{ node_path|e }}">
<summary><span class="inventory-section-icon" aria-hidden="true">{{ inventory_icon(label) }}</span><span class="inventory-branch">{{ inventory_label(label) }}</span><span class="inventory-count">{{ value|length }}</span></summary>
<div class="inventory-children">{% for key, child in value.items() %}{{ inventory_node(key, child, level + 1, node_path) }}{% endfor %}</div>
</details>
{% elif value is sequence and value is not string %}
<details class="inventory-node" data-inventory-tree-path="{{ node_path|e }}">
<summary><span class="inventory-section-icon" aria-hidden="true">{{ inventory_icon(label) }}</span><span class="inventory-branch">{{ inventory_label(label) }}</span><span class="inventory-count">{{ value|length }}</span></summary>
<div class="inventory-children">
{% for child in value %}
{% if child is mapping %}{% set child_label = child.get('inventory.property.name') or child.get('Name') or child.get('name') or child.get('Caption') or child.get('caption') or child.get('DeviceID') or child.get('Tag') or (t('inventory.entry', 'Entry') ~ ' ' ~ loop.index) %}{% else %}{% set child_label = t('inventory.entry', 'Entry') ~ ' ' ~ loop.index %}{% endif %}
{{ inventory_node(child_label, child, level + 1, node_path) }}
{% endfor %}
</div>
</details>
{% else %}
<div class="inventory-property"><span>{{ inventory_label(label) }}</span><strong>{% if value == 'inventory.value.unknown' %}{{ t('inventory.value.unknown', 'Unknown') }}{% elif value is sameas true %}{{ t('common.yes') }}{% elif value is sameas false %}{{ t('common.no') }}{% else %}{{ value if value is not none and value != '' else '' }}{% endif %}</strong></div>
{% endif %}
{% endmacro %}
{% block content %}
<div class="toolbar">
<h1>{{ asset.name }}</h1>
<div class="toolbar-actions">
{% if asset.mesh_node_id %}
<span
class="presence-badge presence-{{ asset.mesh_online_state or 'unknown' }}"
data-presence-asset="{{ asset.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.' ~ (asset.mesh_online_state or 'unknown')) }} · {{ t('presence.last_seen') }}: {% if asset.mesh_last_seen %}{{ asset.mesh_last_seen }}{% else %}{% endif %}"
><span class="presence-dot" aria-hidden="true"></span><span data-presence-label>{{ t('presence.' ~ (asset.mesh_online_state or 'unknown')) }}</span></span>
{% endif %}
{% if is_admin %}
{% if issue_status_name and asset.status == issue_status_name %}<form class="inline-form" method="post" action="/assets/{{asset.id}}/return"><button class="button button-return">↩ {{ t('assets.return') }}</button></form>
{% else %}<button type="button" class="button button-issue" data-issue-action="/assets/{{asset.id}}/issue" data-return-to="detail">↗ {{ t('assets.issue') }}</button>{% endif %}
<a class="button button-duplicate" href="/assets/{{asset.id}}/duplicate">⧉ {{ t('assets.duplicate') }}</a>
<form class="inline-form" method="post" action="/assets/{{ asset.id }}/delete" onsubmit="return confirm({{ t('assets.delete_confirm', name=asset.name)|tojson }});"><button type="submit" class="button button-danger">🗑 {{ t('common.delete') }}</button></form>
<a class="button" href="/assets/{{ asset.id }}/edit">✎ {{ t('common.edit') }}</a>
{% endif %}
<a class="button button-secondary" href="/assets/{{ asset.id }}/history">🕘 {{ t('assets.history') }} ({{ history_count }})</a>
</div>
</div>
<div class="detail">
<div>{% if asset.image_path %}<img class="asset-image" src="{{ asset.image_path }}">{% elif asset.category.image_path %}<img class="asset-image" src="{{ asset.category.image_path }}">{% endif %}</div>
<dl>{% for f in active_fields %}{% if f.field_name not in ['room_number', 'parent_asset_id'] %}{% set d=f.definition %}{% set v=custom_values.get(f.field_name) if d and not d.is_system else asset|attr(f.field_name) %}{% if v %}<dt>{{ t(d.translation_key, d.label) if d else f.label }}</dt><dd>{{ v }}</dd>{% endif %}{% endif %}{% endfor %}{% if asset.room_number %}<dt>{{ t('assets.room') }}</dt><dd>{{ asset.room_number }}</dd>{% endif %}
{% if parent_asset %}<dt>{{ t('assets.parent') }}</dt><dd><a href="/assets/{{ parent_asset.id }}">{{ parent_asset.name }}</a></dd>{% endif %}
{% if child_assets %}<dt>{{ t('assets.child_assets') }}</dt><dd>{% for child in child_assets %}<a href="/assets/{{ child.id }}">{{ child.name }}</a>{% if not loop.last %}, {% endif %}{% endfor %}</dd>{% endif %}
</dl>
</div>
{% if is_admin %}
<section class="panel">
<div class="inventory-header">
<div>
<h2>{{ t('mesh.link.section_title', 'MeshCentral link') }}</h2>
<p class="muted">{{ t('mesh.link.section_help', 'The node ID can be changed manually without deleting the asset. Other asset fields are updated only by the next MeshCentral synchronization.') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/assets/{{ asset.id }}/mesh-link">
✎ {% if asset.mesh_node_id %}{{ t('mesh.link.change', 'Change link') }}{% else %}{{ t('mesh.link.initialize', 'Initialize link') }}{% endif %}
</a>
{% if asset.mesh_node_id %}
<form class="inline-form" method="post" action="/assets/{{ asset.id }}/mesh-unlink" onsubmit="return confirm({{ t('mesh.link.unlink_confirm', 'Remove the MeshCentral link from this asset? The asset itself and its stored data remain unchanged.')|tojson }});">
<button type="submit" class="button button-danger">⛓ {{ t('mesh.link.unlink', 'Remove link') }}</button>
</form>
{% endif %}
</div>
</div>
<div class="inventory-meta">
<span><strong>{{ t('field.mesh_node_id', 'MeshCentral node ID') }}:</strong> <code>{{ asset.mesh_node_id or '' }}</code></span>
{% if asset.mesh_sync_status %}<span><strong>{{ t('assets.status', 'Status') }}:</strong> <span class="sync-status status-{{ asset.mesh_sync_status }}">{{ t('mesh.sync_status.' ~ asset.mesh_sync_status, asset.mesh_sync_status) }}</span></span>{% endif %}
</div>
</section>
{% endif %}
{% if is_admin and asset.mesh_node_id %}
<section class="panel">
<div class="inventory-header">
<div>
<h2>{{ t('jobs.run_definition', 'Job ausführen') }}</h2>
<p class="muted">{{ t('jobs.run_definition_help', 'Es werden nur aktive Jobdefinitionen angezeigt, die zur erkannten Plattform dieses Assets passen.') }}</p>
</div>
{% if job_definitions %}
<form method="post" action="/assets/{{ asset.id }}/jobs/run" class="inline-form" onsubmit="return confirm({{ t('jobs.single.confirm', 'Den ausgewählten Job jetzt auf diesem Asset starten?')|tojson }});">
<select name="definition_id" required aria-label="{{ t('jobs.select_definition', 'Jobdefinition auswählen')|e }}">
<option value="">{{ t('jobs.select_definition', 'Jobdefinition auswählen') }}</option>
{% for definition in job_definitions %}<option value="{{ definition.id }}">{{ definition.name }}</option>{% endfor %}
</select>
<button type="submit" class="button">▶ {{ t('jobs.start', 'Job starten') }}</button>
</form>
{% else %}
<span class="muted">{{ t('jobs.no_compatible_definitions', 'Für diese Plattform ist keine aktive Jobdefinition verfügbar.') }}</span>
{% endif %}
</div>
</section>
{% endif %}
{% if is_admin %}
<section class="panel">
<h2>{{ t('jobparams.asset_title') }}</h2>
<p class="muted">{{ t('jobparams.asset_help') }}</p>
{% if job_definitions %}
<div class="job-definition-asset-list">
{% for definition in job_definitions %}
<details class="job-parameter-asset">
<summary>
<span><strong>{{ definition.name }}</strong>{% if definition.description %}<small>{{ definition.description }}</small>{% endif %}</span>
<span class="muted">{{ t('jobparams.parameter_count', count=definition.parameters|length) }}</span>
</summary>
{% if definition.parameters %}
<form method="post" action="/assets/{{ asset.id }}/job-parameters/{{ definition.id }}" class="form-grid">
{% for parameter in definition.parameters %}
{% set override = job_parameter_overrides.get(parameter.id) %}
<div class="form-wide job-asset-param">
<label class="checkbox-label"><input type="checkbox" name="override_{{ parameter.id }}" {% if override is not none %}checked{% endif %}> {{ t('jobparams.override') }}</label>
<label>{{ parameter.label }} <code>{{ parameter.parameter_key }}</code>
<input name="value_{{ parameter.id }}" value="{{ override if override is not none else parameter.default_value or '' }}">
{% if parameter.description %}<small>{{ parameter.description }}</small>{% endif %}
<small>{{ t('jobparams.default_value') }}: {{ parameter.default_value or '' }} · {{ t('jobparams.source') }}: {{ t('jobparams.source.asset') if override is not none else t('jobparams.source.default') }}</small>
</label>
</div>
{% endfor %}
<div class="form-actions form-wide"><button class="button button-save" type="submit">{{ t('common.save') }}</button></div>
</form>
{% else %}
<p class="muted job-parameter-empty">{{ t('jobparams.none_defined') }}</p>
{% endif %}
</details>
{% endfor %}
</div>
{% else %}
<p class="muted">{{ t('jobs.no_compatible_definitions') }}</p>
{% endif %}
</section>
{% endif %}
{% if asset.mesh_node_id %}
<section class="panel inventory-panel">
<div class="inventory-header">
<div>
<h2>{{ t('inventory.title') }}</h2>
<p class="muted">{{ t('inventory.subtitle') }}</p>
</div>
<a class="button button-secondary" href="/assets/{{ asset.id }}/mesh-inventory.json">{{ t('inventory.download_json') }}</a>
</div>
<div class="inventory-meta">
<span><strong>{{ t('mesh.node_id') }}:</strong> <code>{{ asset.mesh_node_id }}</code></span>
<span><strong>{{ t('inventory.snapshot') }}:</strong> {% if asset.mesh_inventory_updated_at or asset.mesh_last_sync %}<time class="local-time" datetime="{{ utc_iso(asset.mesh_inventory_updated_at or asset.mesh_last_sync) }}" data-utc="{{ utc_iso(asset.mesh_inventory_updated_at or asset.mesh_last_sync) }}">{{ asset.mesh_inventory_updated_at or asset.mesh_last_sync }}</time>{% else %}{{ '' }}{% endif %}</span>
<span><strong>{{ t('assets.status') }}:</strong> <span class="sync-status status-{{ asset.mesh_sync_status }}">{% if asset.mesh_sync_status %}{{ t('mesh.sync_status.' ~ asset.mesh_sync_status, asset.mesh_sync_status) }}{% else %}{{ t('common.unknown') }}{% endif %}</span></span>
<span data-presence-meta="{{ asset.id }}">
<strong>{{ t('presence.title') }}:</strong>
<span data-presence-meta-state>{{ t('presence.' ~ (asset.mesh_online_state or 'unknown')) }}</span>
· {{ t('presence.updated') }}:
<span data-presence-updated>{% if asset.mesh_presence_updated_at %}<time class="local-time" datetime="{{ utc_iso(asset.mesh_presence_updated_at) }}" data-utc="{{ utc_iso(asset.mesh_presence_updated_at) }}">{{ asset.mesh_presence_updated_at }}</time>{% else %}{{ '' }}{% endif %}</span>
· {{ t('presence.last_seen') }}:
<span data-presence-last-seen>{% if asset.mesh_last_seen %}<time class="local-time" datetime="{{ utc_iso(asset.mesh_last_seen) }}" data-utc="{{ utc_iso(asset.mesh_last_seen) }}">{{ asset.mesh_last_seen }}</time>{% else %}{{ '' }}{% endif %}</span>
· {{ t('presence.last_online') }}:
<span data-presence-last-online>{% if asset.mesh_last_online_at %}<time class="local-time" datetime="{{ utc_iso(asset.mesh_last_online_at) }}" data-utc="{{ utc_iso(asset.mesh_last_online_at) }}">{{ asset.mesh_last_online_at }}</time>{% else %}{{ '' }}{% endif %}</span>
</span>
</div>
<div class="inventory-tabs" role="tablist">
<button type="button" class="inventory-tab active" data-inventory-tab="hardware">{{ t('inventory.tab.hardware') }}</button>
<button type="button" class="inventory-tab" data-inventory-tab="software">{{ t('inventory.tab.software') }}{% if installed_software %} ({{ installed_software|length }}){% endif %}</button>
<button type="button" class="inventory-tab" data-inventory-tab="raw">{{ t('inventory.tab.raw_json') }}</button>
<button type="button" class="inventory-tab" data-inventory-tab="sync">{{ t('inventory.tab.sync') }}</button>
</div>
<div class="inventory-tab-content active" data-inventory-panel="hardware">
{% if hardware_inventory %}
<div class="inventory-tree">{% for key, value in hardware_inventory.items() %}{{ inventory_node(key, value) }}{% endfor %}</div>
{% else %}<div class="empty-state">{{ t('inventory.no_hardware') }}</div>{% endif %}
</div>
<div class="inventory-tab-content" data-inventory-panel="software">
<div class="asset-software-toolbar">
<div>
<h3>{{ t('software.installed') }}</h3>
{% if installed_software %}
<p class="muted">{{ t('software.inventory_count', '{count} software entries stored.', count=installed_software|length) }}</p>
{% else %}
<p class="muted">{{ t('software.inventory_not_initialized') }}</p>
{% endif %}
</div>
{% if is_admin %}
<form method="post" action="/assets/{{ asset.id }}/software-inventory" class="inline-form">
<input type="hidden" name="return_to" value="asset-detail">
<button type="submit" class="button">
{% if installed_software %}{{ t('software.update_inventory') }}{% else %}{{ t('software.initialize_inventory') }}{% endif %}
</button>
</form>
{% endif %}
</div>
{% if latest_software_job %}
<div class="software-inventory-meta">
<span><strong>{{ t('software.last_job') }}:</strong> <a href="/software/jobs/{{ latest_software_job.id }}">#{{ latest_software_job.id }}</a></span>
<span><strong>{{ t('software.status') }}:</strong> <span class="job-status job-status-{{ latest_software_job.status }}">{{ t('software.status.' ~ latest_software_job.status, latest_software_job.status) }}</span></span>
<span><strong>{{ t('software.last_inventory') }}:</strong> {% if latest_software_job.finished_at or latest_software_job.created_at %}<time class="local-time" datetime="{{ utc_iso(latest_software_job.finished_at or latest_software_job.created_at) }}" data-utc="{{ utc_iso(latest_software_job.finished_at or latest_software_job.created_at) }}">{{ latest_software_job.finished_at or latest_software_job.created_at }}</time>{% else %}{{ '' }}{% endif %}</span>
</div>
{% endif %}
{% if installed_software %}
<div data-software-selection>
{% if is_admin %}<form method="post" action="/software/inventory/delete" data-software-delete-form data-confirm-template="{{ t('software.delete.confirm')|e }}" data-inventory-only-warning="{{ t('software.delete.inventory_only')|e }}" class="software-delete-toolbar">
<input type="hidden" name="redirect_to" value="/assets/{{ asset.id }}?inventory_tab=software">
<span>{{ t('software.selected_count') }}: <strong data-software-selection-count>0</strong></span>
<button type="submit" class="button button-danger" data-software-delete-button disabled>🗑 {{ t('software.delete.selected') }}</button>
</form>{% endif %}
<div class="table-scroll management-table-scroll">
<table class="data-table" id="asset-installed-software-table" data-storage-key="asset-installed-software-{{ asset.id }}">
<thead><tr>
{% if is_admin %}<th data-no-sort="1"><input type="checkbox" data-software-select-all aria-label="{{ t('software.select_all_visible')|e }}"></th>{% endif %}
<th>{{ t('common.name') }}</th>
<th>{{ t('about.version') }}</th>
<th>{{ t('software.publisher') }}</th>
<th>{{ t('software.architecture') }}</th>
<th>{{ t('software.platform') }}</th>
<th>{{ t('software.install_date') }}</th>
<th>{{ t('software.last_seen') }}</th>
</tr></thead>
<tbody>
{% for entry in installed_software %}
<tr>
{% if is_admin %}<td><input type="checkbox" value="{{ entry.id }}" data-software-entry-checkbox aria-label="{{ t('software.select_entry', name=entry.name)|e }}"></td>{% endif %}
<td>{{ entry.name }}</td>
<td>{{ entry.version or '' }}</td>
<td>{{ entry.publisher or '' }}</td>
<td>{{ entry.architecture or '' }}</td>
<td>{{ t('software.platform.' ~ (entry.platform or 'unknown'), entry.platform or 'unknown') }}</td>
<td data-sort-value="{{ entry.install_date or '' }}">{{ entry.install_date or '' }}</td>
<td data-sort-value="{{ entry.last_seen_at.isoformat() if entry.last_seen_at else '' }}">{% if entry.last_seen_at %}<time class="local-time" datetime="{{ utc_iso(entry.last_seen_at) }}" data-utc="{{ utc_iso(entry.last_seen_at) }}">{{ entry.last_seen_at }}</time>{% else %}{{ '' }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="empty-state software-empty-inventory">
<strong>{{ t('software.inventory_not_initialized') }}</strong>
<span>{{ t('software.inventory_initialize_help') }}</span>
</div>
{% endif %}
</div>
<div class="inventory-tab-content" data-inventory-panel="raw">
{% if raw_inventory %}<pre class="json-viewer">{{ raw_inventory|tojson(indent=2) }}</pre>{% else %}<div class="empty-state">{{ t('inventory.no_raw_data') }}</div>{% endif %}
</div>
<div class="inventory-tab-content" data-inventory-panel="sync">
<dl class="config-list"><dt>{{ t('mesh.last_sync') }}</dt><dd>{{ asset.mesh_last_sync or '' }}</dd><dt>{{ t('mesh.message') }}</dt><dd>{{ asset.mesh_sync_message or '' }}</dd></dl>
{% if asset.mesh_conflicts %}<h3>{{ t('mesh.field_conflicts') }}</h3><table class="data-table"><thead><tr><th>{{ t('common.field') }}</th><th>{{ t('mesh.local_value') }}</th><th>MeshCentral</th><th>{{ t('mesh.rule') }}</th></tr></thead><tbody>{% for field, conflict in asset.mesh_conflicts.items() %}<tr><td>{{ field }}</td><td>{{ conflict.local }}</td><td>{{ conflict.meshcentral }}</td><td>{{ conflict.rule }}</td></tr>{% endfor %}</tbody></table>{% endif %}
</div>
</section>
{% endif %}
<script>
(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('inventory_tab') !== 'software') return;
const button = document.querySelector('[data-inventory-tab="software"]');
button?.click();
})();
</script>
{% 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="{{ today }}" required></label><div class="form-actions"><button type="button" class="button button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit" class="button button-issue">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}
<script src="/static/js/issue-dialog.js"></script>
<script src="/static/js/asset-inventory.js"></script>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends 'base.html' %}{% block content %}
<h1>{% if duplicate_mode %}{{t('assets.duplicate')}}{% elif asset %}{{t('common.edit')}} {{t('app.assets')}}{% else %}{{t('assets.create')}}{% endif %}</h1>
{% if duplicate_mode %}<p class="notice">The original asset data was copied. Asset tag and MeshCentral node ID were cleared.</p>{% endif %}
<form method="post" action="{% if duplicate_mode or not asset %}/assets/new{% else %}/assets/{{asset.id}}/edit{% endif %}" enctype="multipart/form-data" data-category-form>
<label>{{t('assets.category')}}<select name="category_id" onchange="location.href='{% if duplicate_mode %}/assets/new{% elif asset %}/assets/{{asset.id}}/edit{% else %}/assets/new{% endif %}?category_id='+this.value">{% for c in categories %}<option value="{{c.id}}" {% if selected and c.id==selected.id %}selected{% endif %}>{{c.name}}</option>{% endfor %}</select></label>
{% if selected %}{% for f in selected.visible_fields if f.active %}{% set d=f.definition %}{% set custom=d and not d.is_system %}{% set value=custom_values.get(f.field_name) if custom else (asset|attr(f.field_name) if asset else '') %}
<label>{{t(d.translation_key, d.label) if d else f.label}}{% if f.required %} *{% endif %}{% if d and d.description %}<small class="muted">{{d.description}}</small>{% endif %}
{% if f.field_name == 'parent_asset_id' %}<select name="parent_asset_id" {% if f.required %}required{% endif %}><option value=""></option>{% for candidate in parent_candidates %}<option value="{{candidate.id}}" {% if asset and asset.parent_asset_id==candidate.id %}selected{% endif %}>{{candidate.name}}{% if candidate.asset_tag %} ({{candidate.asset_tag}}){% endif %}</option>{% endfor %}</select>
{% elif f.field_name == 'mesh_node_id' and asset and not duplicate_mode %}
<div class="mesh-node-id-editor">
<input id="mesh-node-id-input" name="mesh_node_id" value="{{value or ''}}" autocomplete="off" spellcheck="false" class="mesh-node-id-input">
<div class="mesh-node-id-actions">
<a class="button button-secondary" href="/assets/{{asset.id}}/mesh-link">{{ t('mesh.link.select_existing', 'MeshCentral-Gerät auswählen') }}</a>
<button type="button" class="button button-secondary" id="mesh-node-id-clear">{{ t('mesh.link.clear_field', 'Node-ID leeren') }}</button>
</div>
<small class="muted">{{ t('mesh.link.edit_help', 'Die Node-ID kann direkt bearbeitet werden. Ein leeres Feld löst die Verknüpfung beim Speichern. Andere Assetdaten werden erst beim nächsten MeshCentral-Sync aktualisiert.') }}</small>
</div>
{% elif (d and d.readonly) or field_readonly(f) %}<input value="{{value or ''}}" readonly aria-readonly="true">
{% elif d and d.data_type == 'long_text' %}<textarea name="{{f.field_name}}" {% if f.required %}required{% endif %}>{{value or ''}}</textarea>
{% elif d and d.data_type == 'boolean' %}<select name="{{f.field_name}}"><option value=""></option><option value="true" {% if value in [true,'true','1'] %}selected{% endif %}>{{t('common.yes')}}</option><option value="false" {% if value in [false,'false','0'] %}selected{% endif %}>{{t('common.no')}}</option></select>
{% elif f.field_name == 'status' or (d and d.data_type == 'status') %}<select name="{{f.field_name}}" {% if f.required %}required{% endif %}><option value=""></option>{% for option in status_options %}<option value="{{option.name}}" {% if value==option.name %}selected{% endif %}>{{option.name}}</option>{% endfor %}</select>
{% elif d and d.data_type in ['date','datetime'] %}<input type="{{'datetime-local' if d.data_type=='datetime' else 'date'}}" name="{{f.field_name}}" value="{{html_field_value(value, d.data_type)}}" {% if f.required %}required{% endif %}>
{% elif d and d.data_type in ['integer','decimal'] %}<input type="number" {% if d.data_type=='decimal' %}step="any"{% endif %} name="{{f.field_name}}" value="{{value or ''}}" {% if f.required %}required{% endif %}>
{% elif d and d.data_type == 'json' %}<textarea name="{{f.field_name}}">{{value|tojson if value else ''}}</textarea>
{% else %}<input name="{{f.field_name}}" value="{{value or ''}}" {% if f.required %}required{% endif %}>{% endif %}</label>{% endfor %}{% endif %}
<label>{{t('assets.image', 'Image')}}<input type="file" name="image" accept="image/*"></label><button class="button button-save">💾 {{t('common.save')}}</button></form>
{% if asset and not duplicate_mode %}
<script>
(() => {
const input = document.getElementById('mesh-node-id-input');
const clearButton = document.getElementById('mesh-node-id-clear');
if (!input || !clearButton) return;
clearButton.addEventListener('click', () => {
input.value = '';
input.focus();
input.dispatchEvent(new Event('input', { bubbles: true }));
});
})();
</script>
{% endif %}
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<h1>Historie: {{ asset.name }}</h1>
<div class="toolbar-actions">
<a class="button button-secondary" href="/assets/{{ asset.id }}/history/export.xlsx">Excel exportieren</a>
<a class="button" href="/assets/{{ asset.id }}">Zurück zum Asset</a>
</div>
</div>
<p class="table-hint">Jede geänderte Feldbezeichnung wird mit altem und neuem Wert gespeichert.</p>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>Zeitpunkt</th><th>Quelle</th><th>Benutzer</th><th>Feld</th><th>Alter Wert</th><th>Neuer Wert</th></tr></thead>
<tbody>
{% for entry in entries %}
{% for label, change in entry.changes.items() %}
<tr><td>{{ entry.changed_at }}</td><td>{{ entry.source }}</td><td>{{ entry.changed_by or '' }}</td><td>{{ label }}</td><td class="multiline">{{ change.old }}</td><td class="multiline">{{ change.new }}</td></tr>
{% endfor %}
{% else %}<tr><td colspan="6" class="empty-state">Noch keine Änderungen protokolliert.</td></tr>{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+76
View File
@@ -0,0 +1,76 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('mesh.link.title', 'Change MeshCentral link') }}</h1>
<p class="muted">{{ t('mesh.link.page_help', 'Select an unassigned MeshCentral device. The node ID is changed immediately; other asset fields are updated only during the next synchronization.') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/assets/{{ asset.id }}">← {{ t('common.back', 'Back') }}</a>
</div>
</div>
<section class="panel">
<h2>{{ t('mesh.link.asset_comparison', 'Asset and current link') }}</h2>
<div class="inventory-meta">
<span><strong>{{ t('common.name', 'Name') }}:</strong> {{ asset.name }}</span>
<span><strong>{{ t('field.manufacturer', 'Manufacturer') }}:</strong> {{ asset.manufacturer or '' }}</span>
<span><strong>{{ t('field.serial_number', 'Serial number') }}:</strong> {{ asset.serial_number or '' }}</span>
<span><strong>{{ t('field.mesh_node_id', 'MeshCentral node ID') }}:</strong> <code>{{ asset.mesh_node_id or '' }}</code></span>
</div>
{% if current_device %}
<p class="muted">
{{ t('mesh.link.current_device', 'Current MeshCentral device') }}:
<strong>{{ current_device.name }}</strong>
{% if current_device.manufacturer %} · {{ current_device.manufacturer }}{% endif %}
{% if current_device.serial_number %} · {{ current_device.serial_number }}{% endif %}
</p>
{% elif asset.mesh_node_id %}
<p class="notice warning">{{ t('mesh.link.current_missing', 'The currently stored node ID was not found in MeshCentral. You can replace it with one of the available devices below.') }}</p>
{% endif %}
</section>
<section class="panel">
<h2>{{ t('mesh.link.available_devices', 'Available MeshCentral devices') }}</h2>
<p class="muted">{{ t('mesh.link.available_help', 'Only node IDs that are not assigned to another asset are shown.') }}</p>
{% if available_devices %}
<form method="post" action="/assets/{{ asset.id }}/mesh-link" onsubmit="return confirm({{ t('mesh.link.change_confirm', 'Assign the selected MeshCentral device to this asset?')|tojson }});">
<div class="table-scroll management-table-scroll">
<table class="data-table" data-storage-key="mesh-link-devices">
<thead>
<tr>
<th data-no-sort="1">{{ t('common.select', 'Select') }}</th>
<th>{{ t('common.name', 'Name') }}</th>
<th>{{ t('field.manufacturer', 'Manufacturer') }}</th>
<th>{{ t('field.serial_number', 'Serial number') }}</th>
<th>{{ t('field.mesh_group', 'MeshCentral group') }}</th>
<th>{{ t('field.operating_system', 'Operating system') }}</th>
<th>{{ t('field.mesh_node_id', 'MeshCentral node ID') }}</th>
</tr>
</thead>
<tbody>
{% for device in available_devices %}
<tr>
<td><input type="radio" name="node_id" value="{{ device.node_id|e }}" required aria-label="{{ t('mesh.link.select_device', 'Select device')|e }} {{ device.name|e }}"></td>
<td><strong>{{ device.name or '' }}</strong></td>
<td>{{ device.manufacturer or '' }}</td>
<td>{{ device.serial_number or '' }}</td>
<td>{{ device.group or '' }}</td>
<td>{{ device.operating_system or '' }}</td>
<td><code>{{ device.node_id }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<button type="submit" class="button button-save">{{ t('mesh.link.save', 'Save link') }}</button>
<a class="button button-secondary" href="/assets/{{ asset.id }}">{{ t('common.cancel', 'Cancel') }}</a>
</div>
</form>
{% else %}
<div class="empty-state">{{ t('mesh.link.no_available_devices', 'No unassigned MeshCentral devices are currently available.') }}</div>
{% endif %}
</section>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{ t('software.asset_title', 'Software: {name}').replace('{name}', asset.name) }}</h1><div class="toolbar-actions">{% if is_admin and asset.mesh_node_id %}<form method="post" action="/assets/{{ asset.id }}/software-inventory" class="inline-form"><button type="submit">{% if software %}{{ t('software.update_inventory') }}{% else %}{{ t('software.initialize_inventory') }}{% endif %}</button></form>{% endif %}<a class="button button-secondary" href="/assets/{{ asset.id }}">{{ t('software.back_to_asset') }}</a></div></div>
<section class="panel">
<div class="software-inventory-heading">
<div>
<h2>{{ t('software.installed') }}</h2>
<p class="muted">{% if software %}{{ t('software.inventory_count', '{count} software entries stored.', count=software|length) }}{% else %}{{ t('software.inventory_not_initialized') }}{% endif %}</p>
</div>
<span class="software-inventory-count">{{ software|length }}</span>
</div><div class="table-scroll"><table class="data-table" data-storage-key="asset-installed-software-{{ asset.id }}"><thead><tr><th>{{ t('common.name') }}</th><th>{{ t('about.version') }}</th><th>{{ t('software.publisher') }}</th><th>{{ t('software.architecture') }}</th><th>{{ t('software.install_date') }}</th><th>{{ t('software.last_seen') }}</th></tr></thead><tbody>{% for s in software %}<tr><td>{{ s.name }}</td><td>{{ s.version }}</td><td>{{ s.publisher }}</td><td>{{ s.architecture }}</td><td>{{ s.install_date or '' }}</td><td>{% if s.last_seen_at %}<time class="local-time" datetime="{{ utc_iso(s.last_seen_at) }}" data-utc="{{ utc_iso(s.last_seen_at) }}">{{ s.last_seen_at }}</time>{% else %}{{ '' }}{% endif %}</td></tr>{% else %}<tr><td colspan="6"><div class="software-empty-inventory"><strong>{{ t('software.inventory_not_initialized') }}</strong><span>{{ t('software.inventory_initialize_help') }}</span></div></td></tr>{% endfor %}</tbody></table></div></section>
<section class="panel"><h2>{{ t('software.jobs') }}</h2><div class="table-scroll"><table class="data-table" data-storage-key="asset-software-jobs-{{ asset.id }}"><thead><tr><th>{{ t('software.time') }}</th><th>{{ t('jobs.type') }}</th><th>{{ t('jobs.action') }}</th><th>{{ t('jobs.priority') }}</th><th>{{ t('software.status') }}</th><th>{{ t('software.message') }}</th><th>{{ t('software.log') }}</th><th>{{ t('jobs.actions') }}</th></tr></thead><tbody>{% for j in jobs %}<tr><td>{% if j.created_at %}<time class="local-time" datetime="{{ utc_iso(j.created_at) }}" data-utc="{{ utc_iso(j.created_at) }}">{{ j.created_at }}</time>{% else %}{{ '' }}{% endif %}</td><td>{{ t(j.display_job_type_key, j.display_job_type_fallback) }}</td><td>{{ t('jobs.action.' ~ j.action, j.action) }}</td><td>{{ j.priority }}</td><td><span class="job-status job-status-{{ j.status }}">{{ t('software.status.' ~ j.status, j.status) }}</span></td><td>{{ j.message or '' }}</td><td><a href="/jobs/{{ j.id }}">{{ t('software.show') }}</a></td><td>{% if is_admin and j.status not in ['sending', 'running'] %}<form method="post" action="/jobs/{{ j.id }}/restart" class="inline-form" onsubmit="return confirm({{ t('jobs.restart.confirm')|tojson }});"><button type="submit" class="button button-secondary button-small">↻ {{ t('jobs.restart') }}</button></form>{% else %}<span class="muted"></span>{% endif %}</td></tr>{% else %}<tr><td colspan="8">{{ t('software.no_jobs') }}</td></tr>{% endfor %}</tbody></table></div></section>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% macro category_icon(category_name) %}
{% set category = (category_name or '')|lower %}
<span class="tree-category-icon" title="{{ category_name }}" aria-label="{{ category_name }}">
{% if 'server' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="3" width="16" height="5" rx="1"/><rect x="4" y="10" width="16" height="5" rx="1"/><rect x="4" y="17" width="16" height="4" rx="1"/><circle cx="7" cy="5.5" r=".8"/><circle cx="7" cy="12.5" r=".8"/><circle cx="7" cy="19" r=".8"/></svg>
{% elif 'netz' in category or 'switch' in category or 'router' in category or 'firewall' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M7 11h2m2 0h2m2 0h2M6 14h12"/></svg>
{% elif 'druck' in category or 'printer' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 8V3h10v5M6 17H4V9h16v8h-2M7 14h10v7H7z"/><circle cx="17" cy="11" r=".8"/></svg>
{% elif 'monitor' in category or 'display' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M8 21h8M12 16v5"/></svg>
{% elif 'mobil' in category or 'telefon' in category or 'smartphone' in category or 'tablet' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="2" width="10" height="20" rx="2"/><path d="M10 5h4M11 19h2"/></svg>
{% elif 'computer' in category or 'pc' in category or 'notebook' in category or 'laptop' in category %}
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M7 21h10M12 16v5"/></svg>
{% else %}
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2l8 4v12l-8 4-8-4V6zM4 6l8 4 8-4M12 10v12"/></svg>
{% endif %}
</span>
{% endmacro %}
{% macro render_node(node, by_parent) %}
<li class="tree-item">
<article class="tree-card">
{{ category_icon(node.category.name) }}
<div class="tree-card-body">
<a class="tree-name" href="/assets/{{ node.id }}" title="{{ node.name }}">{{ node.name }}</a>
<div class="tree-meta">{{ node.ip_address or t('assets.no_ip') }}</div>
<div class="tree-meta">{{ node.location or t('assets.no_location') }}{% if node.room_number %} · {{ t('assets.room') }} {{ node.room_number }}{% endif %}</div>
<div class="tree-category">{{ node.category.name }}</div>
</div>
</article>
{% set children = by_parent.get(node.id, []) %}
{% if children %}<ul>{% for child in children %}{{ render_node(child, by_parent) }}{% endfor %}</ul>{% endif %}
</li>
{% endmacro %}
{% block content %}
<div class="toolbar"><h1>{{ t('app.asset_tree') }}</h1></div>
<form class="tree-filters panel" method="get" action="/asset-tree">
<div class="tree-filter-controls">
<label class="tree-filter-field">{{ t('tree.root_node') }}
<select name="root_id">
<option value="">{{ t('tree.all_root_assets') }}</option>
{% for asset in all_roots %}<option value="{{ asset.id }}" {% if selected_root_id == asset.id %}selected{% endif %}>{{ asset.name }}</option>{% endfor %}
</select>
</label>
<label class="tree-filter-field">{{ t('tree.name_contains') }}
<input name="name_filter" value="{{ name_filter }}" placeholder="z. B. Dell">
</label>
<label class="tree-toggle-option">
<input type="checkbox" name="hide_without_parent" value="1" {% if hide_without_parent %}checked{% endif %}>
<span class="tree-toggle-track" aria-hidden="true"><span class="tree-toggle-thumb"></span></span>
<span class="tree-toggle-text">{{ t('tree.hide_standalone') }}</span>
</label>
<div class="tree-filter-actions">
<button class="button" type="submit">{{ t('common.filter') }}</button>
<a class="button button-secondary" href="/asset-tree">{{ t('common.reset') }}</a>
</div>
</div>
<fieldset class="tree-category-filter">
<legend>{{ t('app.categories') }}</legend>
<div class="tree-category-options">
{% for category in categories %}
<label class="tree-category-option">
<input type="checkbox" name="category_id" value="{{ category.id }}" {% if category.id in selected_category_ids %}checked{% endif %}>
<span class="tree-category-check" aria-hidden="true"></span>
<span class="tree-category-name">{{ category.name }}</span>
</label>
{% endfor %}
</div>
<small>{{ t('tree.no_category_selection') }}</small>
</fieldset>
</form>
{% if roots %}<div class="tree-scroll"><ul class="asset-tree">{% for root in roots %}{{ render_node(root, by_parent) }}{% endfor %}</ul></div>{% else %}<p class="notice">{{ t('tree.no_matches') }}</p>{% endif %}
{% endblock %}
+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 %}
+56
View File
@@ -0,0 +1,56 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar"><div><h1>Daten mehrerer Assets ändern</h1><p class="muted">Die Änderung wird auf {{ assets|length }} markierte Assets angewendet.</p></div><a class="button button-secondary" href="/assets{% if category_id %}?category_id={{ category_id }}{% endif %}">Abbrechen</a></div>
<section class="panel bulk-edit-panel">
<form method="post" action="/assets/bulk-edit/apply" id="bulk-edit-apply-form">
{% for id in asset_ids %}<input type="hidden" name="asset_ids" value="{{ id }}">{% endfor %}
{% if category_id %}<input type="hidden" name="category_id" value="{{ category_id }}">{% endif %}
<div class="form-grid">
<label>Feld
<select name="field_name" id="bulk-field" required>
<option value="">Bitte auswählen</option>
{% for field in fields %}<option value="{{ field.field_name }}">{{ field.label }}</option>{% endfor %}
</select>
</label>
<label>Neuer Wert
<input name="new_value" id="bulk-value" autocomplete="off">
<select id="bulk-status-value" class="hidden-control">
<option value="">Leer / nicht gesetzt</option>
{% for status in status_options %}<option value="{{ status.name }}">{{ status.name }}</option>{% endfor %}
</select>
<select id="bulk-parent-value" class="hidden-control">
<option value="">Kein übergeordnetes Gerät</option>
{% for parent in parent_candidates %}<option value="{{ parent.id }}">{{ parent.name }}{% if parent.asset_tag %} {{ parent.asset_tag }}{% endif %} ({{ parent.category.name }})</option>{% endfor %}
</select>
<select id="bulk-category-value" class="hidden-control">
<option value="">Bitte Kategorie auswählen</option>
{% for category in categories %}<option value="{{ category.id }}">{{ category.name }}</option>{% endfor %}
</select>
</label>
</div>
<p class="muted">Ein leerer Wert entfernt den bisherigen Wert. Inventarnummer, Seriennummer und MeshCentral Node-ID sind aus Sicherheitsgründen von der Massenänderung ausgeschlossen.</p>
<details><summary>Ausgewählte Assets anzeigen</summary><ul class="compact-list">{% for asset in assets %}<li>{{ asset.name }} <span class="muted">({{ asset.category.name }})</span></li>{% endfor %}</ul></details>
<div class="form-actions"><a class="button button-secondary" href="/assets{% if category_id %}?category_id={{ category_id }}{% endif %}">Abbrechen</a><button type="submit">💾 Für {{ assets|length }} Assets speichern</button></div>
</form>
</section>
<script>
(() => {
const field = document.getElementById('bulk-field');
const input = document.getElementById('bulk-value');
const status = document.getElementById('bulk-status-value');
const parent = document.getElementById('bulk-parent-value');
const category = document.getElementById('bulk-category-value');
const controls = [input, status, parent, category];
field.addEventListener('change', () => {
const selected = field.value;
const active = selected === 'status' ? status : selected === 'parent_asset_id' ? parent : selected === 'category_id' ? category : input;
controls.forEach(control => {
control.classList.toggle('hidden-control', control !== active);
if (control === active) control.name = 'new_value'; else control.removeAttribute('name');
});
const dateFields = ['assigned_on','purchase_date','warranty_until'];
input.type = dateFields.includes(selected) ? 'date' : 'text';
});
})();
</script>
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block content %}
<div class="toolbar">
<h1>{{ t('assets.import_title') }}</h1>
<div class="toolbar-actions"><a class="button button-secondary" href="/assets">← Zur Asset-Liste</a></div>
</div>
<section class="panel">
<form method="post" action="/assets/import" enctype="multipart/form-data">
<label>Kategorie
<select name="category_id" required>
{% for category in categories %}
<option value="{{ category.id }}" {% if selected and selected.id == category.id %}selected{% endif %}>{{ category.name }}</option>
{% endfor %}
</select>
</label>
{% if selected %}
<p><a class="button button-secondary" href="/assets/import/template.xlsx?category_id={{ selected.id }}">{{ t('assets.import_template') }}</a></p>
{% endif %}
<label>Excel-Datei
<input type="file" name="file" accept=".xlsx" required>
</label>
<label class="checkbox-label">
<input class="checkbox" type="checkbox" name="import_only_active_fields" {% if import_only_active_fields %}checked{% endif %}>
Import only active fields
</label>
<p class="muted">
Standardmäßig werden alle bekannten und für den Excel-Import freigegebenen Felder übernommen,
auch wenn sie in der jeweiligen Kategorie nicht aktiv oder sichtbar sind.
Aktiviere diese Option nur, wenn der Import auf die aktiven Felder der Kategorie begrenzt werden soll.
</p>
<div class="form-actions">
<button type="submit" class="button button-secondary" name="import_mode" value="dry_run">🧪 Test-Import</button>
<button type="submit" class="button button-save" name="import_mode" value="import">{{ t('assets.import_start') }}</button>
</div>
<p class="muted">Der Test-Import prüft dieselbe Datei mit derselben Logik, führt aber am Ende einen vollständigen Datenbank-Rollback durch.</p>
</form>
</section>
{% if result %}
<section class="panel">
<h2>{% if result.dry_run %}Ergebnis des Test-Imports{% else %}Importergebnis{% endif %}</h2>
{% if result.dry_run %}
<div class="notice"><strong>Testlauf:</strong> Es wurden keine Daten gespeichert. Die Zähler und Zeilenergebnisse zeigen, was ein echter Import ausführen würde.</div>
{% endif %}
<dl class="config-list">
<dt>Neu angelegt</dt><dd>{{ result.created }}</dd>
<dt>Aktualisiert</dt><dd>{{ result.updated }}</dd>
<dt>Unverändert</dt><dd>{{ result.skipped }}</dd>
</dl>
{% if result.unknown_headers %}
<h3>Nicht erkannte Spalten</h3>
<p>{{ result.unknown_headers|join(', ') }}</p>
{% endif %}
{% if result.row_results %}
<h3>Ergebnis je Excel-Zeile</h3>
<div class="table-scroll">
<table class="data-table">
<thead><tr><th>Excel-Zeile</th><th>Asset-ID</th><th>Aktion</th><th>Begründung</th></tr></thead>
<tbody>
{% for row in result.row_results %}
<tr>
<td>{{ row.row }}</td>
<td>{{ row.asset_id }}</td>
<td>{{ row.action }}</td>
<td>{{ row.reason }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if result.errors %}
<h3>Fehlerübersicht</h3>
<ul>{% for error in result.errors %}<li>{{ error }}</li>{% endfor %}</ul>
{% endif %}
</section>
{% endif %}
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('duplicates.merge') }}</h1>
<p class="muted">{{ t('duplicates.order_help') }}</p>
</div>
<a class="button button-secondary" href="/assets">{{ t('common.cancel') }}</a>
</div>
<form method="post" action="/assets/merge/preview">
<div class="panel notice">
<strong>{{ t('common.important') }}:</strong> {{ t('duplicates.order_notice') }}
</div>
<div class="table-scroll">
<table class="data-table merge-order-table">
<thead>
<tr>
<th>{{ t('common.order') }}</th><th>{{ t('assets.asset_id') }}</th><th>{{ t('common.category') }}</th><th>{{ t('field.name') }}</th>
<th>{{ t('field.model') }}</th><th>{{ t('field.serial_number') }}</th><th>{{ t('field.hostname') }}</th><th>{{ t('field.notes') }}</th><th>{{ t('field.mesh_node_id') }}</th>
</tr>
</thead>
<tbody id="merge-order-body">
{% for asset in assets %}
<tr draggable="true">
<td class="merge-drag-handle"></td>
<td>{{ asset.id }}<input type="hidden" name="asset_ids" value="{{ asset.id }}"></td>
<td>{{ asset.category.name }}</td>
<td>{{ asset.name }}</td>
<td>{{ asset.model or '' }}</td>
<td>{{ asset.serial_number or '' }}</td>
<td>{{ asset.hostname or '' }}</td>
<td class="multiline">{{ asset.notes or '' }}</td>
<td class="multiline">{{ asset.mesh_node_id or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="form-actions">
<a class="button button-secondary" href="/assets">{{ t('common.cancel') }}</a>
<button type="submit">{{ t('duplicates.preview') }}</button>
</div>
</form>
<script>
(() => {
const body = document.getElementById('merge-order-body');
let dragged = null;
body.querySelectorAll('tr').forEach(row => {
row.addEventListener('dragstart', () => { dragged = row; row.classList.add('dragging'); });
row.addEventListener('dragend', () => { row.classList.remove('dragging'); dragged = null; });
row.addEventListener('dragover', event => {
event.preventDefault();
if (!dragged || dragged === row) return;
const rect = row.getBoundingClientRect();
body.insertBefore(dragged, event.clientY > rect.top + rect.height / 2 ? row.nextSibling : row);
});
});
})();
</script>
{% endblock %}
+38
View File
@@ -0,0 +1,38 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('duplicates.preview_title') }}</h1>
<p class="muted">{{ t('duplicates.preview_help').format(id=assets[0].id) }}</p>
</div>
</div>
<div class="panel">
<h2>{{ t('duplicates.result') }}</h2>
<table class="data-table">
<thead><tr><th>{{ t('common.field') }}</th><th>{{ t('duplicates.result') }}</th><th>{{ t('duplicates.source') }}</th></tr></thead>
<tbody>
{% for item in preview %}
<tr><td>{{ item.label }}</td><td class="multiline">{{ item.value }}</td><td>Asset {{ item.source_asset_id }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="panel">
<h2>{{ t('common.order') }}</h2>
<ol>
{% for asset in assets %}
<li><strong>Asset {{ asset.id }}</strong> {{ asset.name }}{% if loop.first %} ({{ t('duplicates.survives') }}){% else %} ({{ t('duplicates.will_delete') }}){% endif %}</li>
{% endfor %}
</ol>
</div>
<form method="post" action="/assets/merge/apply" onsubmit="return confirm({{ t('duplicates.confirm_apply')|tojson }});">
{% for asset in assets %}<input type="hidden" name="asset_ids" value="{{ asset.id }}">{% endfor %}
<div class="form-actions">
<button type="button" class="button button-secondary" onclick="history.back()">{{ t('duplicates.change_order') }}</button>
<button type="submit" class="button button-danger">{{ t('duplicates.merge_save') }}</button>
</div>
</form>
{% endblock %}
+126
View File
@@ -0,0 +1,126 @@
{% set cfg = application_config() %}{% set appinfo = application_info() %}{% set general = cfg.get('general', {}) %}{% set colors = general.get('colors', {}) %}{% set auth = cfg.get('authentication', {}) %}{% set session_user = request.session.get('user') if request else None %}{% set current_language = (session_user.language_code if session_user else request.session.get('language_code', general.get('default_language', 'en'))) %}{% set theme_mode = session_user.theme_mode if session_user and session_user.theme_mode in ['light','dark'] else general.get('default_theme', 'light') %}
<!doctype html><html lang="{{ current_language }}" data-theme="{{ theme_mode }}"{% if request and request.url.path == '/assets' %} class="asset-page-booting"{% endif %}><head>
<style id="system-theme-colors">
:root{
--am-primary:{{ colors.get('primary_color', '#0878bd') }};
--am-primary-hover:{{ colors.get('primary_hover_color', '#075f94') }};
--am-header:{{ colors.get('header_color', '#063c66') }};
--am-navigation:{{ colors.get('navigation_color', '#ffffff') }};
--am-page-bg:{{ colors.get('page_background', '#f2f4f6') }};
--am-surface:{{ colors.get('surface_color', '#ffffff') }};
--am-text:{{ colors.get('text_color', '#1f2933') }};
--am-border:{{ colors.get('border_color', '#d7dde3') }};
--am-success:{{ colors.get('success_color', '#198754') }};
--am-warning:{{ colors.get('warning_color', '#d97706') }};
--am-danger:{{ colors.get('danger_color', '#b42318') }};
}
</style><script>(()=>{try{if(localStorage.getItem('assetmanager.sidebar.collapsed')==='true')document.documentElement.classList.add('sidebar-collapsed-preset')}catch(_){}})();</script><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title or general.get('title', 'AssetManager') }}</title>{% set favicon_path = general.get('favicon') or general.get('logo') %}{% if favicon_path %}<link rel="icon" href="{{ favicon_path }}">{% endif %}<link rel="stylesheet" href="/static/css/app.css?v={{ application_version() }}"><link rel="stylesheet" href="/custom.css"></head><body{% if request and request.url.path == '/assets' %} class="asset-list-scroll-page"{% endif %} data-record-count-template="{{ t('lists.record_count', 'Angezeigt: {visible} von {total} Datensätzen')|e }}" data-record-count-title="{{ t('lists.record_count_hint', 'Die Anzeige berücksichtigt die aktuell gesetzten Tabellenfilter.')|e }}" data-filter-active-label="{{ t('lists.filter_active', 'Filter aktiv')|e }}" data-filters-active-template="{{ t('lists.filters_active', '{count} Filter aktiv')|e }}" data-filter-status-template="{{ t('lists.filter_status', 'Gefiltert: {visible} von {total} Datensätzen')|e }}" data-clear-filters-label="{{ t('lists.clear_filters', 'Filter zurücksetzen')|e }}" data-persist-browser-state="{{ 'true' if session_user and session_user.persist_browser_state else 'false' }}" data-toast-position="{{ session_user.toast_position if session_user else 'top-right' }}" data-toast-duration="{{ session_user.toast_duration_seconds if session_user else 6 }}">
<header class="main-header">
<a class="brand" href="/">{% if general.get('logo') %}<img src="{{ general.get('logo') }}" alt="Logo">{% endif %}<span>{{ general.get('title', 'AssetManager') }}</span>{% if general.get('company_name') %}<span class="brand-company"> {{ general.get('company_name') }}</span>{% endif %}</a>
<div class="header-actions">
{% if session_user %}
<form method="post" action="/profile/theme" class="header-theme-form" id="header-theme-form">
<input type="hidden" name="theme_mode" value="{{ 'light' if theme_mode == 'dark' else 'dark' }}">
<input type="hidden" name="next_url" value="{{ request.url.path }}">
<button type="submit" class="header-theme-toggle" title="{{ t('appearance.switch_to_light') if theme_mode == 'dark' else t('appearance.switch_to_dark') }}" aria-label="{{ t('appearance.switch_to_light') if theme_mode == 'dark' else t('appearance.switch_to_dark') }}">
<span class="theme-icon-light" aria-hidden="true"></span>
<span class="theme-icon-dark" aria-hidden="true"></span>
</button>
</form>
{% endif %}
<button type="button" class="header-help-button" id="about-dialog-button" title="{{ t('about.open') }}" aria-label="{{ t('about.open') }}">?</button>
<details class="language-menu"><summary>🌐 {{ current_language|upper }} ▾</summary><div class="user-menu-panel">{% for language in available_languages() %}<form method="post" action="/profile/language"><input type="hidden" name="language_code" value="{{ language.code }}"><input type="hidden" name="next_url" value="{{ request.url.path }}"><button type="submit" class="menu-button{% if language.code == current_language %} active{% endif %}">{{ language.native_name }}</button></form>{% endfor %}</div></details>
<details class="user-menu"><summary>👤 {{ session_user.display_name if session_user else t('app.guest') }} ▾</summary><div class="user-menu-panel">{% if session_user %}<a href="/profile">{{ t('app.profile') }}</a><a href="/logout">{{ t('app.logout') }}</a>{% else %}{% if auth.get('mode', 'none') == 'none' %}<span class="muted">Authentication disabled</span>{% endif %}<a href="/login">{{ t('app.login') }}</a>{% endif %}</div></details></div>
</header>
<dialog id="about-dialog" class="about-dialog">
<form method="dialog">
<div class="dialog-heading">
<h2>{{ general.get('title', 'AssetManager') }}</h2>
<button type="submit" class="icon-button button-secondary" aria-label="{{ t('common.close') }}">×</button>
</div>
{% set fixed_manufacturer_logo = manufacturer_logo_path() %}
{% if fixed_manufacturer_logo %}<div class="about-manufacturer-logo"><img src="{{ fixed_manufacturer_logo }}?v={{ application_version() }}" alt=""></div>{% endif %}
<dl class="about-details">
<dt>{{ t('about.version') }}</dt><dd>{{ application_version() }}</dd>
{% if appinfo.author %}<dt>{{ t('about.author') }}</dt><dd>{{ appinfo.author }}</dd>{% endif %}
{% if appinfo.organization %}<dt>{{ t('about.organization') }}</dt><dd>{{ appinfo.organization }}</dd>{% endif %}
{% if appinfo.contact %}<dt>{{ t('about.contact') }}</dt><dd>{{ appinfo.contact }}</dd>{% endif %}
{% if appinfo.license %}<dt>{{ t('about.license') }}</dt><dd>{{ appinfo.license }}</dd>{% endif %}
{% if appinfo.website %}<dt>{{ t('about.website') }}</dt><dd><a href="{{ appinfo.website }}" target="_blank" rel="noopener">{{ appinfo.website }}</a></dd>{% endif %}
{% if appinfo.repository %}<dt>{{ t('about.repository') }}</dt><dd><a href="{{ appinfo.repository }}" target="_blank" rel="noopener">{{ appinfo.repository }}</a></dd>{% endif %}
{% if appinfo.copyright %}<dt>{{ t('about.copyright') }}</dt><dd>{{ appinfo.copyright }}</dd>{% endif %}
</dl>
<div class="form-actions"><button type="submit">{{ t('common.close') }}</button></div>
</form>
</dialog>
<script>
(() => {
const button = document.getElementById('about-dialog-button');
const dialog = document.getElementById('about-dialog');
button?.addEventListener('click', () => dialog?.showModal());
})();
</script>
<div class="app-shell" id="app-shell">
<aside class="side-nav" id="side-nav" aria-label="{{ t('navigation.main', 'Hauptnavigation') }}">
<button type="button" class="side-nav-toggle" id="side-nav-toggle" title="{{ t('navigation.toggle', 'Navigation ein-/ausklappen') }}" aria-label="{{ t('navigation.toggle', 'Navigation ein-/ausklappen') }}"></button>
<a href="/" title="{{ t('app.dashboard') }}"><span class="nav-icon"></span><span class="nav-label">{{ t('app.dashboard') }}</span></a>
<a href="/assets" title="{{ t('app.assets') }}"><span class="nav-icon"></span><span class="nav-label">{{ t('app.assets') }}</span></a>
<a href="/asset-tree" title="{{ t('app.asset_tree') }}"><span class="nav-icon"></span><span class="nav-label">{{ t('app.asset_tree') }}</span></a>
<a href="/charts" title="{{ t('app.charts') }}"><span class="nav-icon"></span><span class="nav-label">{{ t('app.charts') }}</span></a>
{% if session_user and session_user.is_admin %}
<a href="/software" title="{{ t('app.software') }}"><span class="nav-icon"></span><span class="nav-label">{{ t('app.software') }}</span></a>
<a href="/job-definitions" title="{{ t('jobdefs.title') }}"><span class="nav-icon"></span><span class="nav-label">{{ t('jobdefs.title') }}</span></a>
<div class="side-nav-settings">
<a href="/settings" class="settings-trigger" title="{{ t('app.settings') }}"><span class="nav-icon"></span><span class="nav-label">{{ t('app.settings') }}</span><span class="nav-caret"></span></a>
<div class="settings-hover-menu" role="menu">
<a href="/settings/appearance">{{ t('settings.appearance', 'Darstellung') }}</a>
<a href="/settings/fields">{{ t('settings.fields', 'Felder') }}</a>
<a href="/settings/import-export">{{ t('settings.import_export', 'Import / Export') }}</a>
<a href="/categories">{{ t('app.categories', 'Kategorien') }}</a>
<a href="/settings/authentication">{{ t('settings.ldap', 'LDAP') }}</a>
<a href="/sync/meshcentral">{{ t('settings.meshcentral', 'MeshCentral') }}</a>
<a href="/settings/software">{{ t('software.settings.menu') }}</a>
<a href="/settings/backup">{{ t('settings.backup', 'Sicherung') }}</a>
<a href="/status-options">{{ t('app.status_values', 'Statuswerte') }}</a>
<a href="/settings/system">{{ t('settings.system_information', 'Systeminformationen') }}</a>
<a href="/settings/translations">{{ t('settings.translations', 'Übersetzungen') }}</a>
<a href="/users">{{ t('users.management', 'Benutzerverwaltung') }}</a>
</div>
</div>
{% endif %}
</aside>
<main class="app-content">{% block content %}{% endblock %}</main>
</div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js"></script><script src="/static/js/browser-state.js?v={{ app_version }}"></script><script src="/static/js/table-tools.js?v={{ app_version }}"></script><script src="/static/js/list-tools.js"></script><script src="/static/js/asset-columns.js"></script><script src="/static/js/asset-duplicates.js"></script><script src="/static/js/toasts.js"></script><script>
(() => {
document.addEventListener('keydown', event => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
const openDialog = [...document.querySelectorAll('dialog[open]')].pop();
const scope = openDialog || document;
const buttons = [...scope.querySelectorAll('form button[type="submit"], form button:not([type]), form input[type="submit"]')];
const saveButton = buttons.find(button => !button.disabled && (
button.matches('.button-save,[data-save-shortcut]') ||
/speichern|save/i.test(button.textContent || button.value || '')
));
if (!saveButton) return;
event.preventDefault();
saveButton.click();
});
})();
</script><script>
(() => {
const shell = document.getElementById('app-shell');
const toggle = document.getElementById('side-nav-toggle');
if (!shell || !toggle) return;
const key = 'assetmanager.sidebar.collapsed';
if (document.documentElement.classList.contains('sidebar-collapsed-preset')) shell.classList.add('sidebar-collapsed');
shell.classList.add('sidebar-ready');
toggle.addEventListener('click', () => {
shell.classList.toggle('sidebar-collapsed');
const collapsed = shell.classList.contains('sidebar-collapsed');
localStorage.setItem(key, collapsed ? 'true' : 'false');
document.documentElement.classList.toggle('sidebar-collapsed-preset', collapsed);
});
})();
</script><script src="/static/js/asset-page-boot.js"></script><script src="/static/js/presence.js"></script><script src="/static/js/software-inventory-selection.js?v={{ app_version }}"></script>
</body></html>
+296
View File
@@ -0,0 +1,296 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<h1>{{ t('app.categories') }}</h1>
<div class="toolbar-actions">
<button type="button" class="button button-secondary" id="category-bulk-edit-button"
data-base-label="✏ {{ t('categories.bulk.edit', 'Feld ändern')|e }}" disabled>
✏ {{ t('categories.bulk.edit', 'Feld ändern') }}
</button>
<button type="button" class="button button-secondary" id="category-fields-button"
data-base-label="⚙ {{ t('categories.fields.manage', 'Felder verwalten')|e }}" disabled>
⚙ {{ t('categories.fields.manage', 'Felder verwalten') }}
</button>
<button type="submit" class="button button-secondary" form="category-bulk-form" id="activate-defaults-button"
data-base-label="✓ {{ t('categories.activate_default_fields', 'Standardfelder aktivieren')|e }}" disabled>
✓ {{ t('categories.activate_default_fields', 'Standardfelder aktivieren') }}
</button>
<a class="button" href="/categories/new">{{ t('categories.create') }}</a>
</div>
</div>
<form id="category-bulk-form" method="post" action="/categories/activate-default-fields"></form>
<dialog id="category-bulk-edit-dialog">
<form id="category-bulk-edit-form" method="post" action="/categories/bulk-edit" enctype="multipart/form-data">
<div class="dialog-heading">
<h2>{{ t('categories.bulk.dialog_title', 'Markierte Kategorien ändern') }}</h2>
<button type="button" class="icon-button" data-dialog-close aria-label="{{ t('common.cancel') }}">×</button>
</div>
<p id="category-bulk-selected-count" class="muted"></p>
<span id="category-bulk-edit-ids"></span>
<label>{{ t('categories.bulk.field', 'Feld') }}
<select name="field_name" id="category-bulk-field" required>
<option value="description">{{ t('common.description') }}</option>
<option value="name" id="category-name-option">{{ t('common.name') }}</option>
<option value="image">{{ t('categories.bulk.image', 'Kategoriebild') }}</option>
</select>
</label>
<p id="category-name-hint" class="muted">{{ t('categories.bulk.name_hint', 'Der Name kann nur geändert werden, wenn genau eine Kategorie markiert ist.') }}</p>
<label id="category-text-value-wrap">{{ t('categories.bulk.value', 'Neuer Inhalt') }}
<textarea name="field_value" id="category-bulk-value" rows="4"></textarea>
</label>
<label id="category-image-value-wrap" hidden>{{ t('categories.bulk.image', 'Kategoriebild') }}
<input type="file" name="image" accept="image/*">
<span class="muted">{{ t('categories.bulk.image_hint', 'Dasselbe Bild wird allen markierten Kategorien zugewiesen.') }}</span>
</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="clear_value" id="category-bulk-clear"> {{ t('categories.bulk.clear', 'Ausgewähltes Feld leeren') }}</label>
<div class="form-actions">
<button type="button" class="button button-secondary" data-dialog-close>{{ t('common.cancel') }}</button>
<button type="submit">💾 {{ t('common.save') }}</button>
</div>
</form>
</dialog>
<dialog id="category-fields-dialog" class="wide-dialog">
<form id="category-fields-form" method="post" action="/categories/bulk-fields">
<div class="dialog-heading">
<h2>{{ t('categories.fields.manage', 'Felder verwalten') }}</h2>
<button type="button" class="icon-button" data-fields-dialog-close aria-label="{{ t('common.cancel') }}">×</button>
</div>
<p id="category-fields-selected-count" class="muted"></p>
<span id="category-fields-ids"></span>
<div class="dialog-tabs" role="tablist" aria-label="{{ t('categories.fields.manage', 'Felder verwalten') }}">
<button type="button" class="button button-secondary category-fields-tab is-active" data-operation="add">{{ t('categories.fields.add', 'Hinzufügen') }}</button>
<button type="button" class="button button-secondary category-fields-tab" data-operation="remove">{{ t('categories.fields.remove', 'Entfernen') }}</button>
<button type="button" class="button button-secondary category-fields-tab" data-operation="settings">{{ t('categories.fields.settings', 'Einstellungen ändern') }}</button>
<button type="button" class="button button-secondary category-fields-tab" data-operation="copy">{{ t('categories.fields.copy', 'Übernehmen') }}</button>
</div>
<input type="hidden" name="operation" id="category-fields-operation" value="add">
<section class="category-fields-panel" data-panel="add">
<p class="muted">{{ t('categories.fields.add_help', 'Die ausgewählten Felder werden in allen markierten Kategorien aktiviert. Bereits vorhandene Zuordnungen bleiben erhalten.') }}</p>
<div class="field-choice-grid">
{% for definition in definitions %}
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="field_ids" value="{{ definition.id }}"> {{ t(definition.translation_key, definition.label) }}</label>
{% endfor %}
</div>
</section>
<section class="category-fields-panel" data-panel="remove" hidden>
<p class="muted">{{ t('categories.fields.remove_help', 'Die ausgewählten Felder werden in allen markierten Kategorien deaktiviert. Vorhandene Assetdaten bleiben erhalten. Das Namensfeld kann nicht entfernt werden.') }}</p>
<div class="field-choice-grid">
{% for definition in definitions if definition.field_name != 'name' %}
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="field_ids" value="{{ definition.id }}" disabled> {{ t(definition.translation_key, definition.label) }}</label>
{% endfor %}
</div>
</section>
<section class="category-fields-panel" data-panel="settings" hidden>
<p class="muted">{{ t('categories.fields.settings_help', 'Die Einstellungen werden nur für Kategorien geändert, in denen das Feld aktiv ist.') }}</p>
<label>{{ t('categories.bulk.field', 'Feld') }}
<select name="field_definition_id">
<option value="">{{ t('common.select', 'Auswählen') }}</option>
{% for definition in definitions %}<option value="{{ definition.id }}">{{ t(definition.translation_key, definition.label) }}</option>{% endfor %}
</select>
</label>
<div class="settings-bulk-grid">
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="apply_required"> {{ t('categories.fields.change_required', 'Pflichtfeld-Einstellung ändern') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="required_value"> {{ t('categories.required') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="apply_show_in_list"> {{ t('categories.fields.change_list', 'Listenanzeige ändern') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="show_in_list_value"> {{ t('categories.in_list') }}</label>
</div>
</section>
<section class="category-fields-panel" data-panel="copy" hidden>
<p class="muted">{{ t('categories.fields.copy_help', 'Die komplette Feldzuordnung und deren Einstellungen werden aus einer vorhandenen Kategorie auf alle markierten Kategorien übernommen. Nicht in der Vorlage aktive Felder werden in den Zielkategorien deaktiviert; Assetdaten bleiben erhalten.') }}</p>
<label>{{ t('categories.fields.source', 'Vorlagenkategorie') }}
<select name="source_category_id">
<option value="">{{ t('common.select', 'Auswählen') }}</option>
{% for c in categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
</select>
</label>
<div class="notice warning">{{ t('categories.fields.copy_warning', 'Die Feldkonfiguration der markierten Kategorien wird an die Vorlage angeglichen. Kategorienamen, Beschreibungen, Bilder und Assets werden nicht verändert.') }}</div>
</section>
<div class="form-actions">
<button type="button" class="button button-secondary" data-fields-dialog-close>{{ t('common.cancel') }}</button>
<button type="submit">💾 {{ t('common.save') }}</button>
</div>
</form>
</dialog>
<div class="table-scroll management-table-scroll">
<table class="data-table categories-table">
<thead>
<tr>
<th class="selection-column" data-no-sort="1" data-no-filter="1">
<input type="checkbox" id="select-all-categories" aria-label="{{ t('categories.select_all', 'Alle Kategorien markieren') }}">
</th>
<th>{{ t('common.name') }}</th>
<th>{{ t('common.assets') }}</th>
<th>{{ t('categories.fields_total', 'Felder gesamt') }}</th>
<th>{{ t('categories.active_fields') }}</th>
<th>{{ t('categories.list_fields', 'In Liste') }}</th>
<th>{{ t('categories.attributes', 'Aktive Attribute') }}</th>
<th data-no-filter="1" data-no-sort="1">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
{% for c in categories %}
{% set count = asset_counts.get(c.id, 0) %}
{% set active_fields = c.visible_fields|selectattr('active')|list %}
{% set list_fields = active_fields|selectattr('show_in_list')|list %}
<tr>
<td class="selection-column">
<input class="category-select" type="checkbox" form="category-bulk-form" name="category_ids" value="{{ c.id }}"
aria-label="{{ c.name|e }} {{ t('common.select', 'markieren') }}">
</td>
<td>{{ c.name }}</td>
<td>{{ count }}</td>
<td>{{ c.visible_fields|length }}</td>
<td>{{ active_fields|length }}</td>
<td>{{ list_fields|length }}</td>
<td class="category-attributes">
{% for field in active_fields %}
<span class="attribute-chip{% if field.show_in_list %} attribute-chip-list{% endif %}" title="{{ t('categories.shown_in_list', 'In Liste sichtbar') if field.show_in_list else t('categories.active_only', 'Aktiv, aber nicht in Liste sichtbar') }}">{{ field_label(field) }}</span>
{% else %}
<span class="muted">{{ t('categories.no_active_fields', 'Keine aktiven Felder') }}</span>
{% endfor %}
</td>
<td class="category-actions">
<div class="action-buttons">
<a class="button button-secondary" href="/categories/{{ c.id }}/edit" title="{{ t('common.edit') }}">{{ t('common.edit') }}</a>
<button type="submit" class="button button-secondary" form="duplicate-category-{{ c.id }}" title="{{ t('common.duplicate') }}">{{ t('common.duplicate') }}</button>
<button type="submit" class="button button-danger" form="delete-category-{{ c.id }}"
{% if count > 0 %}disabled title="{{ t('categories.in_use', 'Kategorie wird noch verwendet') }}"{% else %}title="{{ t('common.delete') }}"{% endif %}>
{{ t('common.delete') }}
</button>
</div>
<form id="duplicate-category-{{ c.id }}" method="post" action="/categories/{{ c.id }}/duplicate"></form>
<form id="delete-category-{{ c.id }}" method="post" action="/categories/{{ c.id }}/delete"
onsubmit="return confirm('{{ t('categories.confirm_delete', 'Kategorie wirklich löschen? Das ist nur möglich, wenn sie von keinem Asset verwendet wird.')|e }}');"></form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<script>
(() => {
const boxes = [...document.querySelectorAll('.category-select')];
const all = document.getElementById('select-all-categories');
const defaultsButton = document.getElementById('activate-defaults-button');
const editButton = document.getElementById('category-bulk-edit-button');
const fieldsButton = document.getElementById('category-fields-button');
const fieldsDialog = document.getElementById('category-fields-dialog');
const fieldsIds = document.getElementById('category-fields-ids');
const fieldsSelectedCount = document.getElementById('category-fields-selected-count');
const fieldsOperation = document.getElementById('category-fields-operation');
const dialog = document.getElementById('category-bulk-edit-dialog');
const editIds = document.getElementById('category-bulk-edit-ids');
const selectedCount = document.getElementById('category-bulk-selected-count');
const fieldSelect = document.getElementById('category-bulk-field');
const nameOption = document.getElementById('category-name-option');
const nameHint = document.getElementById('category-name-hint');
const textWrap = document.getElementById('category-text-value-wrap');
const imageWrap = document.getElementById('category-image-value-wrap');
const clearBox = document.getElementById('category-bulk-clear');
const visibleBoxes = () => boxes.filter(box => !box.closest('tr')?.hidden && getComputedStyle(box.closest('tr')).display !== 'none');
const selectedBoxes = () => boxes.filter(box => box.checked);
function refresh() {
const selected = selectedBoxes();
const count = selected.length;
const visible = visibleBoxes();
const visibleSelected = visible.filter(box => box.checked).length;
const defaultsLabel = defaultsButton.dataset.baseLabel || '✓ {{ t('categories.activate_default_fields', 'Standardfelder aktivieren')|e }}';
const fieldsLabel = fieldsButton.dataset.baseLabel || '⚙ {{ t('categories.fields.manage', 'Felder verwalten')|e }}';
const editLabel = editButton.dataset.baseLabel || '✏ {{ t('categories.bulk.edit', 'Feld ändern')|e }}';
defaultsButton.disabled = count === 0;
defaultsButton.textContent = count ? `${defaultsLabel} (${count})` : defaultsLabel;
fieldsButton.disabled = count === 0;
fieldsButton.textContent = count ? `${fieldsLabel} (${count})` : fieldsLabel;
editButton.disabled = count === 0;
editButton.textContent = count ? `${editLabel} (${count})` : editLabel;
all.checked = visible.length > 0 && visibleSelected === visible.length;
all.indeterminate = visibleSelected > 0 && visibleSelected < visible.length;
}
function updateFieldUi() {
const isImage = fieldSelect.value === 'image';
imageWrap.hidden = !isImage;
textWrap.hidden = isImage;
clearBox.checked = false;
}
boxes.forEach(box => box.addEventListener('change', refresh));
all?.addEventListener('change', () => {
visibleBoxes().forEach(box => box.checked = all.checked);
refresh();
});
editButton?.addEventListener('click', () => {
const selected = selectedBoxes();
if (!selected.length) return;
editIds.replaceChildren(...selected.map(box => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'category_ids';
input.value = box.value;
return input;
}));
const countTemplate = {{ t('categories.bulk.selected_count', '{count} Kategorien ausgewählt')|tojson }};
selectedCount.textContent = countTemplate.replace('{count}', selected.length);
nameOption.disabled = selected.length !== 1;
nameHint.hidden = selected.length === 1;
if (nameOption.disabled && fieldSelect.value === 'name') fieldSelect.value = 'description';
updateFieldUi();
dialog.showModal();
});
fieldsButton?.addEventListener('click', () => {
const selected = selectedBoxes();
if (!selected.length) return;
fieldsIds.replaceChildren(...selected.map(box => {
const input = document.createElement('input');
input.type = 'hidden'; input.name = 'category_ids'; input.value = box.value;
return input;
}));
const countTemplate = {{ t('categories.bulk.selected_count', '{count} Kategorien ausgewählt')|tojson }};
fieldsSelectedCount.textContent = countTemplate.replace('{count}', selected.length);
fieldsDialog.showModal();
});
function selectFieldsOperation(operation) {
fieldsOperation.value = operation;
fieldsDialog.querySelectorAll('.category-fields-tab').forEach(button => button.classList.toggle('is-active', button.dataset.operation === operation));
fieldsDialog.querySelectorAll('.category-fields-panel').forEach(panel => panel.hidden = panel.dataset.panel !== operation);
// Only controls in the active panel are submitted. This prevents duplicate
// field_ids from the add/remove panels.
fieldsDialog.querySelectorAll('.category-fields-panel').forEach(panel => {
panel.querySelectorAll('input, select, textarea').forEach(control => control.disabled = panel.dataset.panel !== operation);
});
}
fieldsDialog?.querySelectorAll('.category-fields-tab').forEach(button => button.addEventListener('click', () => selectFieldsOperation(button.dataset.operation)));
fieldsDialog?.querySelectorAll('[data-fields-dialog-close]').forEach(button => button.addEventListener('click', () => fieldsDialog.close()));
selectFieldsOperation('add');
fieldSelect?.addEventListener('change', updateFieldUi);
dialog?.querySelectorAll('[data-dialog-close]').forEach(button => button.addEventListener('click', () => dialog.close()));
// Table filters can change row visibility without changing checkboxes. Keep the
// selection state consistent whenever the shared table tools signal activity.
document.addEventListener('input', event => {
if (event.target.closest('.categories-table')) requestAnimationFrame(refresh);
});
document.addEventListener('change', event => {
if (event.target.closest('.categories-table')) requestAnimationFrame(refresh);
});
refresh();
})();
</script>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends 'base.html' %}{% block content %}
<h1>{% if category %}{{ t('categories.edit') }}{% else %}{{ t('categories.create') }}{% endif %}</h1>
<form method="post" enctype="multipart/form-data">
<label>{{ t('common.name') }}<input name="name" value="{{category.name if category else ''}}" required></label>
<label>{{ t('common.description') }}<textarea name="description">{{category.description if category and category.description else ''}}</textarea></label>
<label>{{ t('categories.image') }}<input type="file" name="image" accept="image/*"></label>
<h2>{{ t('categories.fields_title') }}</h2>
<p class="muted">Labels, descriptions, data types and read-only settings are managed centrally under Settings → Fields.</p>
<table class="data-table"><thead><tr><th>{{ t('common.active') }}</th><th>{{ t('categories.database_field') }}</th><th>{{ t('categories.display_name') }}</th><th>{{ t('categories.required') }}</th><th>{{ t('categories.in_list') }}</th><th>{{ t('categories.readonly') }}</th></tr></thead><tbody>
{% for definition in definitions %}{% set cfg=selected_fields.get(definition.field_name) %}
<tr><td><input class="checkbox" type="checkbox" name="active_{{definition.id}}" {% if (cfg and cfg.active) or definition.field_name == 'name' %}checked{% endif %}{% if definition.field_name == 'name' %} disabled{% endif %}></td>
<td><code>{{definition.field_name}}</code></td><td><strong>{{ t(definition.translation_key, definition.label) }}</strong>{% if definition.description %}<div class="muted">{{definition.description}}</div>{% endif %}</td>
<td><input class="checkbox" type="checkbox" name="required_{{definition.id}}" {% if (cfg and cfg.required) or definition.field_name == 'name' %}checked{% endif %}{% if definition.readonly or definition.field_name == 'name' %} disabled{% endif %}></td>
<td><input class="checkbox" type="checkbox" name="list_{{definition.id}}" {% if (cfg and cfg.show_in_list) or definition.field_name == 'name' %}checked{% endif %}></td>
<td>{{ t('common.yes') if definition.readonly else t('common.no') }}</td></tr>{% endfor %}</tbody></table>
<button>💾 {{ t('common.save') }}</button></form>{% endblock %}
+35
View File
@@ -0,0 +1,35 @@
{% extends 'base.html' %}
{% block content %}
<h1>{% if chart %}{{ t('charts.edit') }}{% else %}{{ t('charts.create') }}{% endif %}</h1>
{% if chart and chart.is_system %}<p class="notice">{{ t('charts.system_help') }}</p>{% endif %}
<form method="post" action="{% if chart %}/charts/{{ chart.id }}/edit{% else %}/charts/new{% endif %}">
<label>{{ t('common.name') }}<input name="name" value="{{ chart.name if chart else '' }}" required></label>
<label>{{ t('common.description') }}<textarea name="description">{{ chart.description or '' if chart else '' }}</textarea></label>
<div class="form-grid">
<label>{{ t('charts.type') }}<select name="chart_type">{% for value,label in chart_types.items() %}<option value="{{ value }}" {% if chart and chart.chart_type==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label>{{ t('charts.group_by') }}<select name="group_field">{% for value,label in chart_fields.items() %}<option value="{{ value }}" {% if chart and chart.group_field==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label>{{ t('charts.stack_field') }}<select name="stack_field"><option value=""> {{ t('common.none', 'none') }} </option>{% for value,label in chart_fields.items() %}<option value="{{ value }}" {% if chart and chart.stack_field==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label>{{ t('charts.sorting') }}<select name="sort_mode"><option value="count_desc" {% if not chart or chart.sort_mode=='count_desc' %}selected{% endif %}>{{ t('charts.count_desc') }}</option><option value="alpha" {% if chart and chart.sort_mode=='alpha' %}selected{% endif %}>{{ t('charts.alphabetical') }}</option></select></label>
<label>{{ t('charts.max_items') }}<input type="number" name="max_items" min="1" max="50" value="{{ chart.max_items if chart else 12 }}"></label>
<label>{{ t('charts.value_display') }}<select name="value_mode"><option value="raw" {% if not chart or chart.value_mode=='raw' %}selected{% endif %}>{{ t('charts.raw_values') }}</option><option value="automatic" {% if chart and chart.value_mode=='automatic' %}selected{% endif %}>{{ t('charts.auto_group') }}</option><option value="custom" {% if chart and chart.value_mode=='custom' %}selected{% endif %}>{{ t('charts.custom_mappings') }}</option></select></label>
</div>
<label><input type="checkbox" name="dashboard_visible" {% if chart and chart.dashboard_visible %}checked{% endif %}> {{ t('charts.show_dashboard') }}</label>
<label><input type="checkbox" name="show_empty" {% if chart and chart.show_empty %}checked{% endif %}> {{ t('charts.include_empty') }}</label>
<fieldset>
<legend>{{ t('charts.status_filter') }}</legend>
<p class="help-text">{{ t('charts.status_filter_help') }}</p>
<div class="checkbox-grid">
{% for option in status_options %}
<label class="checkbox-option"><input type="checkbox" name="excluded_statuses" value="{{ option.name }}" {% if option.name in excluded_statuses %}checked{% endif %}> {{ option.name }}</label>
{% else %}<p>{{ t('charts.no_status_values') }}</p>{% endfor %}
</div>
</fieldset>
<label>{{ t('charts.custom_value_mappings') }}
<textarea name="value_mappings_text" rows="8" placeholder="Microsoft Windows 11 => Windows 11">{{ value_mappings_text }}</textarea>
<small>{{ t('charts.mapping_help') }}</small>
</label>
<div class="form-actions"><button class="button button-save" type="submit">💾 {{ t('common.save') }}</button><a class="button" href="/charts/manage">{{ t('common.cancel') }}</a></div>
</form>
{% endblock %}
+4
View File
@@ -0,0 +1,4 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><div><h1>{{ t('app.charts') }}</h1><p class="muted">{{ t('charts.visible_assets_hint') }}</p></div>{% if is_admin %}<a class="button" href="/charts/manage">⚙ {{ t('charts.manage') }}</a>{% endif %}</div>
<div class="chart-grid">{% for item in charts %}{% include '_chart.html' %}{% else %}<div class="empty-state">{{ t('charts.none') }}</div>{% endfor %}</div>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<h1>{{ t('charts.manage') }}</h1>
<a class="button button-save" href="/charts/new"> {{ t('charts.create') }}</a>
</div>
<section class="panel">
<table class="data-table">
<thead><tr><th>{{ t('common.name') }}</th><th>{{ t('charts.type') }}</th><th>{{ t('charts.grouping') }}</th><th>{{ t('charts.status_filter') }}</th><th>{{ t('charts.dashboard') }}</th><th>{{ t('common.actions') }}</th></tr></thead>
<tbody>
{% for chart in charts %}
<tr>
<td><strong>{{ chart.name }}</strong>{% if chart.is_system %}<br><small>{{ t('charts.system_chart') }}</small>{% endif %}{% if chart.description %}<br><small>{{ chart.description }}</small>{% endif %}</td>
<td>{{ chart_types.get(chart.chart_type, chart.chart_type) }}</td>
<td>{{ chart_fields.get(chart.group_field, chart.group_field) }}{% if chart.stack_field %} / {{ chart_fields.get(chart.stack_field, chart.stack_field) }}{% endif %}</td>
<td>{% if chart.excluded_statuses %}{{ t('charts.excluded') }}: {{ chart.excluded_statuses|join(', ') }}{% else %}{% endif %}</td>
<td>{{ t('common.yes') if chart.dashboard_visible else t('common.no') }}</td>
<td class="actions">
<a class="button" href="/charts/{{ chart.id }}/edit">✏ {{ t('common.edit') }}</a>
<form method="post" action="/charts/{{ chart.id }}/duplicate" class="inline-form"><button type="submit" class="button">⧉ {{ t('common.duplicate') }}</button></form>
{% if not chart.is_system %}<form method="post" action="/charts/{{ chart.id }}/delete" class="inline-form" onsubmit="return confirm({{ t('charts.delete_confirm')|tojson }});"><button type="submit" class="button button-danger">🗑 {{ t('common.delete') }}</button></form>{% endif %}
</td>
</tr>
{% else %}<tr><td colspan="6">{{ t('charts.none') }}</td></tr>{% endfor %}
</tbody>
</table>
</section>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar"><h1>{{ t('dashboard.overview') }}</h1>{% if is_admin %}<a class="button" href="/assets/new"> {{ t('assets.create') }}</a>{% endif %}</div>
<div class="cards">{% for c in categories %}<a class="card" href="/assets?category_id={{c.id}}">{% if c.image_path %}<img src="{{c.image_path}}">{% else %}<div class="placeholder">{{c.name[:1]}}</div>{% endif %}<div><strong>{{c.name}}</strong><small>{{c.assets|length}} {{ t('common.assets') }}</small></div></a>{% endfor %}</div>
{% if dashboard_charts %}<div class="section-heading"><h2>{{ t('app.charts') }}</h2><a href="/charts">{{ t('charts.show_all') }}</a></div><div class="chart-grid dashboard-charts">{% for item in dashboard_charts %}{% include '_chart.html' %}{% endfor %}</div>{% endif %}
<h2>{{ t('dashboard.recently_changed') }}</h2>
<p class="table-hint">{{ t('dashboard.list_fields_hint') }}</p>
<div class="table-scroll sticky-table"><table class="data-table" id="dashboard-asset-table"><thead><tr><th>{{ t('assets.name') }}</th><th>{{ t('assets.category') }}</th>{% for f in fields if f.field_name != 'name' %}<th>{{ t('field.' ~ f.field_name, f.label) }}</th>{% endfor %}<th data-no-sort="1" data-no-filter="1">{{ t('common.actions') }}</th></tr></thead><tbody>
{% for a in assets %}<tr><td><a href="/assets/{{a.id}}">{{a.name}}</a></td><td>{{a.category.name}}</td>{% for f in fields if f.field_name != 'name' %}<td class="multiline">{{a|attr(f.field_name) or ''}}</td>{% endfor %}<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="dashboard"><button class="icon-button button-return" title="{{ t('assets.return') }}"></button></form>{% else %}<button type="button" class="icon-button button-issue" data-issue-action="/assets/{{a.id}}/issue" data-return-to="dashboard" title="{{ t('assets.issue') }}"></button>{% endif %}<a class="icon-button button-duplicate" href="/assets/{{a.id}}/duplicate" title="{{ t('assets.duplicate') }}"></a><form class="inline-form" method="post" action="/assets/{{a.id}}/delete" onsubmit="return confirm({{ t('assets.delete_confirm', name=a.name)|tojson }});"><button class="icon-button button-danger" title="{{ t('common.delete') }}">🗑</button></form>{% else %}<span class="muted">{{ t('common.read_only') }}</span>{% endif %}</td></tr>{% else %}<tr><td colspan="40" class="empty-state">{{ t('assets.none') }}</td></tr>{% endfor %}
</tbody></table></div>
{% 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" 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>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends 'base.html' %}{% block content %}<h1>{% if definition %}Edit field{% else %}Add field{% endif %}</h1>
<form method="post" class="settings-form">
{% if not definition %}<label>Technical field name<input name="field_name" pattern="[a-zA-Z0-9_]+" required><small>Letters, numbers and underscores only.</small></label>{% else %}<label>Technical field name<input value="{{definition.field_name}}" readonly></label>{% endif %}
<label>Label<input name="label" value="{{definition.label if definition else ''}}" required></label><label>Description<textarea name="description">{{definition.description or '' if definition else ''}}</textarea></label>
<label>Data type<select name="data_type">{% for key,label in data_types.items() %}<option value="{{key}}" {% if definition and definition.data_type==key %}selected{% endif %}>{{label}}</option>{% endfor %}</select></label><label>{{ t('fields.list_width', 'Breite in Listen (Pixel)') }}
<input type="number" name="list_width" min="40" max="800" step="10" value="{{ definition.list_width if definition and definition.list_width else '' }}" placeholder="automatisch">
<small>{{ t('fields.list_width_hint', 'Leer = automatische Breite. Sinnvoll sind etwa 60 für kurze Werte und 180300 für längere Texte.') }}</small>
</label>
<div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="readonly" {% if definition and definition.readonly %}checked{% endif %}{% if definition and definition.field_name in ['mesh_node_id','mesh_mtype'] %} disabled{% endif %}> Read only</label>{% if definition and not definition.is_system %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_active" {% if definition.is_active %}checked{% endif %}> Active</label>{% endif %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_default" {% if definition and definition.is_default %}checked{% endif %}> Standardfeld</label></div>
<div class="checkbox-row">{% for key,label in [('searchable','Searchable'),('filterable','Filterable'),('chart_enabled','Available for charts'),('excel_import','Excel import'),('excel_export','Excel export'),('bulk_edit','Bulk edit')] %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="{{key}}" {% if not definition or definition|attr(key) %}checked{% endif %}> {{label}}</label>{% endfor %}</div>
<button class="button button-save">💾 {{t('common.save')}}</button></form>{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>Fields</h1><div class="toolbar-actions"><a class="button" href="/settings/fields/new"> Add field</a><a class="button button-secondary" href="/settings">{{ t('common.back') }}</a></div></div>
<p class="muted">Central labels and data types apply immediately to all categories, forms, tables and exports.</p>
<div class="sticky-table management-table-scroll"><table class="data-table"><thead><tr><th>Field</th><th>Label</th><th>Description</th><th>Data type</th><th>Read only</th><th>System</th><th>Stored values</th><th>{{ t('common.actions') }}</th></tr></thead><tbody>
{% for d in definitions %}<tr><td><code>{{d.field_name}}</code></td><td>{{d.label}}</td><td>{{d.description or ''}}</td><td>{{data_types.get(d.data_type,d.data_type)}}</td><td>{{t('common.yes') if d.readonly else t('common.no')}}</td><td>{{t('common.yes') if d.is_system else t('common.no')}}</td><td>{{value_counts.get(d.id,0)}}</td><td><a class="button button-secondary" href="/settings/fields/{{d.id}}/edit">{{t('common.edit')}}</a>{% if not d.is_system %}<form class="inline-form" method="post" action="/settings/fields/{{d.id}}/delete" onsubmit="return confirm('Delete this field and all values?');"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="confirm_values">Confirm values</label><button class="button button-danger">{{t('common.delete')}}</button></form>{% endif %}</td></tr>{% endfor %}
</tbody></table></div>{% endblock %}
+182
View File
@@ -0,0 +1,182 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar"><div><h1>{{ t('jobdefs.edit') if definition else t('jobdefs.new') }}</h1><p class="muted">{{ t('jobdefs.admin_only') }}</p></div><a class="button button-secondary" href="/job-definitions">{{ t('common.back') }}</a></div>
<form method="post" action="{{ '/job-definitions/' ~ definition.id if definition else '/job-definitions' }}" class="panel form-grid job-definition-form" id="job-definition-form">
<label class="checkbox-label job-enabled-top form-wide"><input type="checkbox" name="enabled" {% if not definition or definition.enabled %}checked{% endif %}> {{ t('common.active') }}</label>
<label>{{ t('common.name') }}<input name="name" required maxlength="180" value="{{ definition.name if definition else '' }}"></label>
<label>{{ t('jobdefs.kind') }}<select name="job_kind" id="job-kind"><option value="script" {% if not definition or definition.job_kind=='script' %}selected{% endif %}>{{ t('jobdefs.kind.script') }}</option><option value="registry" {% if definition and definition.job_kind=='registry' %}selected{% endif %}>{{ t('jobdefs.kind.registry') }}</option></select><small>{{ t('jobdefs.kind.help') }}</small></label>
<label>{{ t('jobdefs.platform') }}<select name="platform">{% for x in ['windows','linux','macos','all'] %}<option value="{{x}}" {% if definition and definition.platform==x %}selected{% endif %}>{{ t('jobdefs.platform.'~x,x) }}</option>{% endfor %}</select></label>
<label class="script-only-field">{{ t('jobdefs.interpreter') }}<select name="interpreter" id="job-interpreter">{% for x in ['powershell','cmd','bash','python'] %}<option value="{{x}}" {% if definition and definition.interpreter==x %}selected{% endif %}>{{ t('jobdefs.interpreter.'~x,x) }}</option>{% endfor %}</select></label>
<label class="script-only-field">{{ t('jobdefs.source') }}<select name="source_type" id="job-source-type">{% for x in ['inline','mounted','unc'] %}<option value="{{x}}" {% if definition and definition.source_type==x %}selected{% endif %}>{{ t('jobdefs.source.'~x,x) }}</option>{% endfor %}</select></label>
<label class="form-wide">{{ t('common.description') }}<textarea name="description" rows="2">{{ definition.description if definition else '' }}</textarea></label>
<label class="form-wide job-source-path">{{ t('jobdefs.path') }}<input name="source_path" value="{{ definition.source_path if definition else '' }}" placeholder="/scripts/windows/example.ps1 oder \\server\share\script.ps1"><small>{{ t('jobdefs.path_help') }}</small></label>
<div class="form-wide job-inline-script">
<label>{{ t('jobdefs.own_script') }}<textarea id="job-inline-script" name="inline_script" rows="18" spellcheck="false" class="code-editor">{{ definition.inline_script if definition else '' }}</textarea><small>{{ t('jobdefs.own_script_help') }}</small></label>
<div class="form-actions" style="margin-top:.75rem"><button class="button button-secondary" type="button" id="insert-job-test-script">{{ t('jobdefs.insert_test_commands') }}</button></div>
<section class="job-script-help" aria-live="polite">
<h3>{{ t('jobdefs.script_help.title') }}</h3>
<p>{{ t('jobdefs.script_help.common') }}</p>
<div class="job-script-help-common">
<p><strong>{{ t('jobdefs.script_help.variables') }}</strong></p>
{% raw %}<code>{{JobId}}</code>, <code>{{AssetName}}</code>, <code>{{Hostname}}</code>, <code>{{IPAddress}}</code>, <code>{{CurrentUser}}</code>, <code>{{SerialNumber}}</code>, <code>{{Manufacturer}}</code>, <code>{{Model}}</code>, <code>{{Department}}</code>, <code>{{Location}}</code>, <code>{{MeshNodeId}}</code>{% endraw %}
<p><strong>{{ t('jobdefs.script_help.parameters') }}</strong> {% raw %}<code>{{Param.ParameterKey}}</code>{% endraw %}</p>
</div>
<div data-script-help="powershell">
<p>{{ t('jobdefs.script_help.powershell') }}</p>
<pre class="sync-log">$count = 3
Write-JobLog "Gefundene Dateien: $count"
$JobResult["processed_count"] = $count</pre>
</div>
<div data-script-help="python">
<p>{{ t('jobdefs.script_help.python') }}</p>
<pre class="sync-log">count = 3
print(f"Processed files: {count}")
job_result["processed_count"] = count</pre>
</div>
<div data-script-help="bash">
<p>{{ t('jobdefs.script_help.bash') }}</p>
<pre class="sync-log">count=3
echo "Processed files: $count"
{% raw %}echo "Asset: {{Hostname}}"{% endraw %}</pre>
</div>
<div data-script-help="cmd">
<p>{{ t('jobdefs.script_help.cmd') }}</p>
<pre class="sync-log">set "COUNT=3"
echo Processed files: %COUNT%
{% raw %}echo Asset: {{Hostname}}{% endraw %}</pre>
</div>
<p class="muted" id="job-source-helper-note">{{ t('jobdefs.script_help.wrapper_sources') }}</p>
</section>
</div>
<section class="form-wide job-registry-editor" hidden>
<div class="toolbar"><div><h2>{{ t('registry.title') }}</h2><p class="muted">{{ t('registry.help') }}</p></div><button type="button" class="button button-secondary" id="add-registry-entry">{{ t('registry.add') }}</button></div>
<label class="checkbox-label"><input type="checkbox" name="registry_backup" {% if definition and definition.configuration and definition.configuration.get('registry_backup') %}checked{% endif %}> {{ t('registry.backup') }}</label>
<div class="registry-parameter-help"><strong>{{ t('registry.parameters.title') }}</strong><p>{{ t('registry.parameters.body') }}</p><code>{% raw %}{{Param.TechnicalKey}}{% endraw %}</code><div id="registry-parameter-keys" class="muted"></div></div>
<input type="hidden" name="registry_entries_json" id="registry-entries-json" value="[]">
<datalist id="registry-rights-list"><option value="ReadKey"><option value="SetValue"><option value="CreateSubKey"><option value="WriteKey"><option value="ChangePermissions"><option value="FullControl"></datalist>
<div id="registry-entry-rows"></div>
<details class="job-script-help"><summary>{{ t('registry.notes.title') }}</summary><p>{{ t('registry.notes.body') }}</p><pre class="sync-log">HKLM\SOFTWARE\Example
Name: Enabled
Type: REG_DWORD
{% raw %}Value: {{Param.Enabled}}{% endraw %}</pre></details>
</section>
<section class="form-wide job-parameters-editor"><div class="toolbar"><div><h2>{{ t('jobparams.title') }}</h2><p class="muted">{{ t('jobparams.help') }}</p></div><button type="button" class="button button-secondary" id="add-job-parameter">{{ t('jobparams.add') }}</button></div>
<input type="hidden" name="parameters_json" id="job-parameters-json" value="[]">
<div id="job-parameter-rows"></div></section>
<label class="script-only-field">{{ t('jobdefs.arguments') }}<input name="arguments" value="{{ definition.arguments if definition else '' }}"></label>
<label>{{ t('jobdefs.timeout') }}<input type="number" name="timeout_seconds" min="1" max="86400" value="{{ definition.timeout_seconds if definition else 300 }}"></label>
<label class="script-only-field">{{ t('jobdefs.success_codes') }}<input name="success_codes" value="{{ definition.success_codes if definition else '0' }}"></label>
<div class="form-actions form-wide"><button class="button button-save" type="submit">{{ t('common.save') }}</button>{% if definition %}<button class="button button-secondary" type="submit" formaction="/job-definitions/{{ definition.id }}/test">{{ t('jobdefs.test') }}</button>{% endif %}</div>
</form>
{% if test_result %}<section class="panel"><h2>{{ t('jobdefs.test_result') }}</h2><pre class="sync-log">{{ test_result }}</pre></section>{% endif %}
{% if script_preview is not none %}<section class="panel"><div class="toolbar"><div><h2>{{ t('jobdefs.preview') }}</h2><p class="muted">{{ t('jobdefs.preview_help') }}</p></div><button class="button button-secondary" type="button" id="copy-job-preview">{{ t('jobdefs.copy_preview') }}</button></div><pre class="sync-log" id="job-script-preview">{{ script_preview }}</pre>{% if preview_values %}<details><summary>{{ t('jobdefs.preview.sample_notice') }}</summary><pre class="sync-log">{% for key,value in preview_values.items() %}{{ key }} = {{ value }}
{% endfor %}</pre></details>{% endif %}</section>{% endif %}
<style>
.registry-entry-row{border:1px solid var(--border-color,#415166);border-radius:8px;padding:1rem;margin-top:1rem}.registry-entry-row [hidden]{display:none!important}.registry-mode-picker{border:0;padding:0;margin:0}.registry-mode-options{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:.6rem}.registry-mode-options label{display:flex;align-items:flex-start;gap:.55rem;border:1px solid var(--border-color,#415166);border-radius:7px;padding:.75rem;cursor:pointer}.registry-mode-options label:has(input:checked){outline:2px solid var(--accent-color,#20a5df);background:rgba(32,165,223,.08)}.registry-mode-options input{margin-top:.2rem}.registry-mode-options span{display:flex;flex-direction:column;gap:.2rem}.registry-mode-options small{opacity:.75}.registry-set-fields,.registry-permission-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.registry-checkbox-stack{display:flex;flex-direction:column;align-items:stretch;gap:.55rem;grid-column:1/-1;width:100%}.registry-checkbox-stack .checkbox-label,.job-definition-form .checkbox-label{display:flex!important;flex-direction:row!important;align-items:center!important;justify-content:flex-start!important;gap:.55rem!important;width:100%!important;max-width:none!important;text-align:left!important}.registry-checkbox-stack .checkbox-label input,.job-definition-form .checkbox-label input{flex:0 0 auto!important;margin:0!important}.registry-parameter-help{border-left:3px solid var(--accent-color,#20a5df);padding:.65rem .85rem;margin:.75rem 0}.registry-parameter-help p{margin:.25rem 0}.script-only-field[hidden]{display:none!important}.registry-delete-value-fields{display:grid;grid-template-columns:minmax(0,1fr);gap:.75rem}@media(max-width:800px){.registry-set-fields,.registry-permission-fields{grid-template-columns:1fr}}
</style>
<script>
(() => {
const kind=document.getElementById('job-kind'), source=document.getElementById('job-source-type'), interpreter=document.getElementById('job-interpreter'), inlineArea=document.querySelector('.job-inline-script'), pathArea=document.querySelector('.job-source-path'), registryArea=document.querySelector('.job-registry-editor');
const sync=()=>{const isRegistry=kind?.value==='registry';const x=!isRegistry&&source?.value==='inline';if(inlineArea)inlineArea.style.display=x?'block':'none';if(pathArea)pathArea.style.display=(!isRegistry&&!x)?'flex':'none';if(registryArea)registryArea.hidden=!isRegistry;document.querySelectorAll('.script-only-field').forEach(el=>{el.hidden=isRegistry;el.querySelectorAll('input,select,textarea').forEach(c=>c.disabled=isRegistry)});document.querySelectorAll('[data-script-help]').forEach(el=>el.hidden=el.dataset.scriptHelp!==interpreter?.value)}; kind?.addEventListener('change',sync);source?.addEventListener('change',sync);interpreter?.addEventListener('change',sync);sync();
const initial=[{% if definition %}{% for x in definition.parameters %}{{ {'parameter_key':x.parameter_key,'label':x.label,'description':x.description or '','data_type':x.data_type,'default_value':x.default_value or '','required':x.required}|tojson }}{% if not loop.last %},{% endif %}{% endfor %}{% endif %}];
const rows=document.getElementById('job-parameter-rows'), hidden=document.getElementById('job-parameters-json');
const esc=v=>String(v??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
function addRow(p={}){const d=document.createElement('div');d.className='job-parameter-row';d.innerHTML=`<label>{{ t('jobparams.key') }}<input data-p="parameter_key" value="${esc(p.parameter_key)}" pattern="[A-Za-z0-9_]+"></label><label>{{ t('jobparams.label') }}<input data-p="label" value="${esc(p.label)}"></label><label>{{ t('jobparams.type') }}<select data-p="data_type">${['text','number','boolean','path'].map(x=>`<option value="${x}" ${p.data_type===x?'selected':''}>${x}</option>`).join('')}</select></label><label>{{ t('jobparams.default') }}<input data-p="default_value" value="${esc(p.default_value)}"></label><label class="form-wide">{{ t('common.description') }}<textarea data-p="description" rows="2">${esc(p.description)}</textarea></label><label class="checkbox-label"><input type="checkbox" data-p="required" ${p.required?'checked':''}> {{ t('jobparams.required') }}</label><button type="button" class="button button-danger" data-remove>{{ t('common.delete') }}</button>`;d.querySelector('[data-remove]').onclick=()=>d.remove();rows.appendChild(d)}
const updateRegistryParameterKeys=()=>{const target=document.getElementById('registry-parameter-keys');if(!target)return;const keys=[...rows.querySelectorAll('[data-p="parameter_key"]')].map(x=>x.value.trim()).filter(Boolean);target.textContent=keys.length?{{ t('registry.parameters.available')|tojson }}+' '+keys.map(k=>String.fromCharCode(123,123)+'Param.'+k+String.fromCharCode(125,125)).join(', '):{{ t('registry.parameters.none')|tojson }}};
initial.forEach(addRow); document.getElementById('add-job-parameter')?.addEventListener('click',()=>{addRow({data_type:'text'});updateRegistryParameterKeys()}); rows.addEventListener('input',updateRegistryParameterKeys); rows.addEventListener('click',()=>setTimeout(updateRegistryParameterKeys)); updateRegistryParameterKeys();
document.getElementById('insert-job-test-script')?.addEventListener('click',()=>{const e=document.getElementById('job-inline-script');if(e)e.value={{ t('jobdefs.test_commands_content')|tojson }}});
const registryInitial={{ (definition.configuration.get('registry_entries', []) if definition and definition.configuration else [])|tojson }};
const registryRows=document.getElementById('registry-entry-rows'), registryHidden=document.getElementById('registry-entries-json');
function addRegistryRow(p={}){
const d=document.createElement('div');
d.className='job-parameter-row registry-entry-row';
const legacyPermission=p.action==='set_permissions';
let mode='set';
if(legacyPermission || (p.action==='set' && p.set_value===false && p.permissions_enabled)) mode='permissions';
else if(p.action==='delete_value') mode='delete_value';
else if(p.action==='delete_key') mode='delete_key';
const knownPrincipals={'S-1-5-32-545':'builtin_users','S-1-5-32-544':'builtin_admins','S-1-5-18':'system','S-1-5-11':'authenticated_users','S-1-1-0':'everyone'};
const principalValue=p.principal||'S-1-5-32-545';
const principalChoice=knownPrincipals[principalValue]||'custom';
const knownRights=['ReadKey','WriteKey','FullControl'];
const rightsValue=p.rights||'FullControl';
const rightsChoice=knownRights.includes(rightsValue)?rightsValue:'custom';
const id='registry-mode-'+Math.random().toString(36).slice(2);
d.innerHTML=`
<fieldset class="registry-mode-picker form-wide">
<legend>{{ t('registry.operation') }}</legend>
<div class="registry-mode-options">
<label><input type="radio" name="${id}" value="set" ${mode==='set'?'checked':''}><span><strong>{{ t('registry.action.set') }}</strong><small>{{ t('registry.action.set.help') }}</small></span></label>
<label><input type="radio" name="${id}" value="permissions" ${mode==='permissions'?'checked':''}><span><strong>{{ t('registry.action.set_permissions') }}</strong><small>{{ t('registry.action.set_permissions.help') }}</small></span></label>
<label><input type="radio" name="${id}" value="delete_value" ${mode==='delete_value'?'checked':''}><span><strong>{{ t('registry.action.delete_value') }}</strong><small>{{ t('registry.action.delete_value.help') }}</small></span></label>
<label><input type="radio" name="${id}" value="delete_key" ${mode==='delete_key'?'checked':''}><span><strong>{{ t('registry.action.delete_key') }}</strong><small>{{ t('registry.action.delete_key.help') }}</small></span></label>
</div>
</fieldset>
<input type="hidden" data-r="action" value="${esc(p.action||'set')}">
<input type="hidden" data-r="set_value" value="${p.set_value!==false?'true':'false'}">
<input type="hidden" data-r="permissions_enabled" value="${p.permissions_enabled?'true':'false'}">
<label>{{ t('registry.hive') }}<select data-r="hive">${['HKLM','HKCU','HKCR','HKU','HKCC'].map(x=>`<option value="${x}" ${p.hive===x?'selected':''}>${x}</option>`).join('')}</select></label>
<label class="form-wide">{{ t('registry.path') }}<input data-r="path" required value="${esc(p.path)}" placeholder="SOFTWARE\\Vendor\\Product oder {% raw %}{{Param.RegistryPath}}{% endraw %}"><small>{{ t('registry.path.help') }}</small></label>
<div class="registry-set-fields form-wide">
<label>{{ t('registry.name') }}<input data-r="name" value="${esc(p.name)}"></label>
<label>{{ t('registry.type') }}<select data-r="value_type">${['REG_SZ','REG_EXPAND_SZ','REG_MULTI_SZ','REG_DWORD','REG_QWORD','REG_BINARY'].map(x=>`<option value="${x}" ${p.value_type===x?'selected':''}>${x}</option>`).join('')}</select></label>
<label class="form-wide">{{ t('registry.value') }}<textarea data-r="value" rows="2" placeholder="{% raw %}{{Param.Value}}{% endraw %}">${esc(p.value)}</textarea></label>
<div class="registry-checkbox-stack"><label class="checkbox-label"><input type="checkbox" data-r="only_if_missing" ${p.only_if_missing?'checked':''}> {{ t('registry.only_if_missing') }}</label></div>
</div>
<div class="registry-delete-value-fields form-wide">
<label>{{ t('registry.name') }}<input data-r-display="delete-name" value="${esc(p.name)}"></label>
</div>
<div class="registry-permission-fields form-wide">
<label>{{ t('registry.principal') }}
<select data-principal-choice>
<option value="builtin_users" ${principalChoice==='builtin_users'?'selected':''}>{{ t('registry.principal.users') }} [S-1-5-32-545]</option>
<option value="builtin_admins" ${principalChoice==='builtin_admins'?'selected':''}>{{ t('registry.principal.admins') }} [S-1-5-32-544]</option>
<option value="system" ${principalChoice==='system'?'selected':''}>SYSTEM [S-1-5-18]</option>
<option value="authenticated_users" ${principalChoice==='authenticated_users'?'selected':''}>{{ t('registry.principal.authenticated') }} [S-1-5-11]</option>
<option value="everyone" ${principalChoice==='everyone'?'selected':''}>{{ t('registry.principal.everyone') }} [S-1-1-0]</option>
<option value="custom" ${principalChoice==='custom'?'selected':''}>{{ t('registry.custom') }}</option>
</select>
</label>
<label class="registry-custom-principal">{{ t('registry.principal_custom') }}<input data-principal-custom value="${esc(principalChoice==='custom'?principalValue:'')}"><small>{{ t('registry.principal_help') }}</small></label>
<input type="hidden" data-r="principal" value="${esc(principalValue)}">
<label>{{ t('registry.rights') }}
<select data-rights-choice>
<option value="ReadKey" ${rightsChoice==='ReadKey'?'selected':''}>{{ t('registry.rights.read') }}</option>
<option value="WriteKey" ${rightsChoice==='WriteKey'?'selected':''}>{{ t('registry.rights.change') }}</option>
<option value="FullControl" ${rightsChoice==='FullControl'?'selected':''}>{{ t('registry.rights.full') }}</option>
<option value="custom" ${rightsChoice==='custom'?'selected':''}>{{ t('registry.custom') }}</option>
</select>
</label>
<label class="registry-custom-rights">{{ t('registry.rights_custom') }}<input data-rights-custom list="registry-rights-list" value="${esc(rightsChoice==='custom'?rightsValue:'')}"></label>
<input type="hidden" data-r="rights" value="${esc(rightsValue)}">
<label>{{ t('registry.access_type') }}<select data-r="access_type"><option value="Allow" ${p.access_type!=='Deny'?'selected':''}>Allow</option><option value="Deny" ${p.access_type==='Deny'?'selected':''}>Deny</option></select></label>
<div class="registry-checkbox-stack">
<label class="checkbox-label"><input type="checkbox" data-r="inherit_subkeys" ${p.inherit_subkeys!==false?'checked':''}> {{ t('registry.inherit_subkeys') }}</label>
<label class="checkbox-label"><input type="checkbox" data-r="replace_existing" ${p.replace_existing!==false?'checked':''}> {{ t('registry.replace_existing') }}</label>
</div>
</div>
<button type="button" class="button button-danger" data-remove>{{ t('common.delete') }}</button>`;
const modeInputs=[...d.querySelectorAll(`input[name="${id}"]`)];
const actionField=d.querySelector('[data-r="action"]');
const setValueField=d.querySelector('[data-r="set_value"]');
const permissionsField=d.querySelector('[data-r="permissions_enabled"]');
const valueName=d.querySelector('[data-r="name"]');
const deleteName=d.querySelector('[data-r-display="delete-name"]');
const principalMap={builtin_users:'S-1-5-32-545',builtin_admins:'S-1-5-32-544',system:'S-1-5-18',authenticated_users:'S-1-5-11',everyone:'S-1-1-0'};
const syncPrincipal=()=>{const c=d.querySelector('[data-principal-choice]').value;const custom=d.querySelector('[data-principal-custom]');d.querySelector('.registry-custom-principal').hidden=c!=='custom';d.querySelector('[data-r="principal"]').value=c==='custom'?custom.value:(principalMap[c]||'')};
const syncRights=()=>{const c=d.querySelector('[data-rights-choice]').value;const custom=d.querySelector('[data-rights-custom]');d.querySelector('.registry-custom-rights').hidden=c!=='custom';d.querySelector('[data-r="rights"]').value=c==='custom'?custom.value:c};
const syncRegistryFields=()=>{const m=modeInputs.find(x=>x.checked)?.value||'set';actionField.value=(m==='set'||m==='permissions')?'set':m;setValueField.value=m==='set'?'true':'false';permissionsField.value=m==='permissions'?'true':'false';const sections={set:d.querySelector('.registry-set-fields'),delete_value:d.querySelector('.registry-delete-value-fields'),permissions:d.querySelector('.registry-permission-fields')};Object.entries(sections).forEach(([key,section])=>{const active=m===key;section.hidden=!active;section.setAttribute('aria-hidden',active?'false':'true');section.querySelectorAll('input,select,textarea').forEach(control=>control.disabled=!active)});if(m==='delete_value') deleteName.value=valueName.value; else if(deleteName.value) valueName.value=deleteName.value};
modeInputs.forEach(x=>x.addEventListener('change',syncRegistryFields));
deleteName.addEventListener('input',()=>valueName.value=deleteName.value);
d.querySelector('[data-principal-choice]').addEventListener('change',syncPrincipal);d.querySelector('[data-principal-custom]').addEventListener('input',syncPrincipal);
d.querySelector('[data-rights-choice]').addEventListener('change',syncRights);d.querySelector('[data-rights-custom]').addEventListener('input',syncRights);
syncPrincipal();syncRights();syncRegistryFields();
d.querySelector('[data-remove]').onclick=()=>d.remove();registryRows.appendChild(d)
}
registryInitial.forEach(addRegistryRow);document.getElementById('add-registry-entry')?.addEventListener('click',()=>addRegistryRow({action:'set',hive:'HKLM',value_type:'REG_SZ'}));
document.getElementById('job-definition-form')?.addEventListener('submit',()=>{hidden.value=JSON.stringify([...rows.children].map(r=>{const o={};r.querySelectorAll('[data-p]').forEach(x=>o[x.dataset.p]=x.type==='checkbox'?x.checked:x.value);return o}));registryHidden.value=JSON.stringify([...registryRows.children].map(r=>{const o={};r.querySelectorAll('[data-r]').forEach(x=>{let v=x.type==='checkbox'?x.checked:x.value;if((x.dataset.r==='set_value'||x.dataset.r==='permissions_enabled')&&typeof v==='string')v=v==='true';o[x.dataset.r]=v});return o}))});
document.getElementById('copy-job-preview')?.addEventListener('click',async()=>{await navigator.clipboard.writeText(document.getElementById('job-script-preview')?.textContent||'')});
})();
</script>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('jobdefs.title') }}</h1>
<p class="muted">{{ t('jobdefs.description') }}</p>
</div>
<a class="button button-save" href="/job-definitions/new">{{ t('jobdefs.new') }}</a>
</div>
<section class="panel">
<form method="get" action="/job-definitions" class="jobdefs-filter-form">
<label class="jobdefs-filter-search">
<span>{{ t('jobdefs.filter.search') }}</span>
<input type="search" name="q" value="{{ search_text }}" placeholder="{{ t('jobdefs.filter.search_placeholder') }}">
</label>
<input type="hidden" id="jobdefs-active-only-value" name="active_only" value="{{ '1' if active_only else '0' }}">
<label class="jobdefs-active-filter">
<input id="jobdefs-active-only" type="checkbox" {% if active_only %}checked{% endif %} onchange="document.getElementById('jobdefs-active-only-value').value=this.checked?'1':'0'">
<span>{{ t('jobdefs.filter.active_only') }}</span>
</label>
<button class="button button-secondary" type="submit">{{ t('jobdefs.filter.apply') }}</button>
{% if search_text or not active_only %}<a class="button button-secondary" href="/job-definitions">{{ t('jobdefs.filter.reset') }}</a>{% endif %}
</form>
</section>
<div class="panel table-panel">
<table class="data-table">
<thead>
<tr>
<th>{{ t('jobdefs.status') }}</th>
<th>{{ t('common.name') }}</th>
<th>{{ t('jobdefs.platform') }}</th>
<th>{{ t('jobdefs.interpreter') }}</th>
<th>{{ t('jobdefs.source') }}</th>
<th>{{ t('jobdefs.revision') }}</th>
<th>{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody>
{% for row in definition_rows %}
{% set item = row.item %}
<tr>
<td>
<div class="jobdefs-status-list">
<span class="software-settings-badge">{{ t('jobdefs.status.active') if item.enabled else t('jobdefs.status.inactive') }}</span>
{% if item.is_system %}<span class="software-settings-badge">{{ t('jobdefs.system') }}</span>{% endif %}
</div>
</td>
<td><strong>{{ item.name }}</strong><div class="muted">{{ item.description or '' }}</div></td>
<td>{{ t('jobdefs.platform.' ~ item.platform, item.platform) }}</td>
<td>{{ t('jobdefs.interpreter.' ~ item.interpreter, item.interpreter) }}</td>
<td>{{ t('jobdefs.source.' ~ item.source_type, item.source_type) }}</td>
<td>{{ item.revision }}</td>
<td>
<div class="table-actions">
<a class="button button-secondary" href="/job-definitions/{{ item.id }}/edit">{{ t('common.edit') }}</a>
{% if row.can_delete %}
<form method="post" action="/job-definitions/{{ item.id }}/delete" class="inline-form" onsubmit="return confirm({{ t('jobdefs.delete.confirm')|tojson }});">
<button class="button button-danger" type="submit">{{ t('common.delete') }}</button>
</form>
{% else %}
<button class="button button-danger" type="button" disabled title="{{ t('jobdefs.delete.system_forbidden') if row.delete_reason == 'system' else t('jobdefs.delete.referenced') }}">{{ t('common.delete') }}</button>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr><td colspan="7">{{ t('jobdefs.none_filtered') if search_text or active_only else t('jobdefs.none') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<section class="panel"><h2>{{ t('jobdefs.delete.behavior_title') }}</h2><p>{{ t('jobdefs.delete.behavior') }}</p></section>
<style>
.jobdefs-filter-form{display:flex;align-items:end;gap:1rem;flex-wrap:wrap}
.jobdefs-filter-search{display:grid;gap:.35rem;min-width:min(26rem,100%);flex:1}
.jobdefs-active-filter{display:flex;align-items:center;gap:.5rem;min-height:2.5rem;white-space:nowrap}
.jobdefs-status-list{display:flex;gap:.35rem;flex-wrap:wrap}
</style>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
echo "AssetManager test job"
echo "JobId={{JobId}}"
echo "AssetName={{AssetName}}"
echo "Hostname={{Hostname}}"
echo "IPAddress={{IPAddress}}"
echo "Department={{Department}}"
echo "Location={{Location}}"
echo "MeshNodeId={{MeshNodeId}}"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -Eeuo pipefail
CALLBACK_URL='{{CallbackUrl}}'
JOB_ID='{{JobId}}'
callback() {
local status="$1" code="$2" message="$3" attempt
for attempt in 1 2 3; do
if curl -fsS --max-time 60 -X POST -H 'Content-Type: application/json' --data "{\"status\":\"${status}\",\"exit_code\":${code},\"completed\":true,\"message\":\"${message}\"}" "$CALLBACK_URL" >/dev/null; then
return 0
fi
echo "Callback attempt ${attempt} of 3 failed" >&2
if [ "$attempt" -lt 3 ]; then sleep $((attempt * 2)); fi
done
return 1
}
trap 'code=$?; callback failed "$code" "Job failed" || true; exit "$code"' ERR
echo "$(date --iso-8601=seconds) job=${JOB_ID} started"
# --- Eigene Befehle hier einfügen ---
# --- Ende eigene Befehle ---
callback success 0 'Job completed'
+8
View File
@@ -0,0 +1,8 @@
echo AssetManager test job
echo JobId={{JobId}}
echo AssetName={{AssetName}}
echo Hostname={{Hostname}}
echo IPAddress={{IPAddress}}
echo Department={{Department}}
echo Location={{Location}}
echo MeshNodeId={{MeshNodeId}}
+15
View File
@@ -0,0 +1,15 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "JOB_ID={{JobId}}"
set "CALLBACK_URL={{CallbackUrl}}"
rem --- Eigene Befehle hier einfügen ---
rem --- Ende eigene Befehle ---
set "EXIT_CODE=%ERRORLEVEL%"
if not "%EXIT_CODE%"=="0" goto :failed
powershell -NoProfile -ExecutionPolicy Bypass -Command "$body=@{status='success';exit_code=0;completed=$true;message='Job completed'}|ConvertTo-Json -Compress; $last=$null; for($attempt=1;$attempt -le 3;$attempt++){try{Invoke-RestMethod -Method Post -Uri '%CALLBACK_URL%' -ContentType 'application/json' -Body $body -TimeoutSec 60|Out-Null; exit 0}catch{$last=$_.Exception; if($attempt -lt 3){Start-Sleep -Seconds (2*$attempt)}}}; Write-Error ('Callback failed after 3 attempts: '+$last.Message); exit 1"
if errorlevel 1 exit /b 1
exit /b 0
:failed
powershell -NoProfile -ExecutionPolicy Bypass -Command "$body=@{status='failed';exit_code=%EXIT_CODE%;completed=$true;message='Job failed'}|ConvertTo-Json -Compress; for($attempt=1;$attempt -le 3;$attempt++){try{Invoke-RestMethod -Method Post -Uri '%CALLBACK_URL%' -ContentType 'application/json' -Body $body -TimeoutSec 60|Out-Null; break}catch{if($attempt -lt 3){Start-Sleep -Seconds (2*$attempt)}}}"
exit /b %EXIT_CODE%
+8
View File
@@ -0,0 +1,8 @@
Write-JobLog "AssetManager test job"
Write-JobLog "JobId={{JobId}}"
Write-JobLog "AssetName={{AssetName}}"
Write-JobLog "Hostname={{Hostname}}"
Write-JobLog "IPAddress={{IPAddress}}"
Write-JobLog "Department={{Department}}"
Write-JobLog "Location={{Location}}"
Write-JobLog "MeshNodeId={{MeshNodeId}}"
+45
View File
@@ -0,0 +1,45 @@
$ErrorActionPreference = 'Stop'
$log = Join-Path $env:ProgramData 'AssetManager\commands.log'
$clientLog = [System.Collections.Generic.List[string]]::new()
$JobResult = @{}
New-Item -ItemType Directory -Force -Path (Split-Path $log) | Out-Null
function Send-JobCallback([hashtable]$Data) {
$json = $Data | ConvertTo-Json -Depth 8 -Compress
$bodyBytes = [Text.Encoding]::UTF8.GetBytes($json)
$lastError = $null
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Invoke-RestMethod -Method Post -Uri '{{CallbackUrl}}' -ContentType 'application/json; charset=utf-8' -Body $bodyBytes -TimeoutSec 60 | Out-Null
if ($attempt -gt 1) { Write-JobLog ("Callback accepted on attempt $attempt of 3") }
return
} catch {
$lastError = $_.Exception
Write-JobLog ("Callback attempt $attempt of 3 failed: " + $_.Exception.Message)
if ($attempt -lt 3) { Start-Sleep -Seconds (2 * $attempt) }
}
}
throw ("Callback failed after 3 attempts: " + $lastError.Message)
}
function Write-JobLog([string]$Message) {
$line = "$(Get-Date -Format o) job={{JobId}} $Message"
$clientLog.Add($line)
Add-Content -Path $log -Value $line -Encoding UTF8
Write-Output $line
}
try {
Write-JobLog 'Job started'
try { Send-JobCallback @{status='running'; completed=$false; message='Job started on client'; log=($clientLog -join "`n")} } catch { Write-JobLog ("Initial callback failed: " + $_.Exception.Message) }
# --- Eigene Befehle hier einfügen ---
# --- Ende eigene Befehle ---
Write-JobLog 'Job completed'
$bodyData = @{status='success'; exit_code=0; completed=$true; message='Job completed'; log=($clientLog -join "`n")}
if ($JobResult -is [System.Collections.IDictionary]) { foreach ($key in $JobResult.Keys) { $bodyData[$key] = $JobResult[$key] } }
$body = $bodyData | ConvertTo-Json -Depth 8 -Compress
Send-JobCallback $bodyData
} catch {
Write-JobLog ("Job failed: " + $_.Exception.Message)
$body = @{status='failed'; exit_code=1; completed=$true; message=$_.Exception.Message; log=($clientLog -join "`n"); stderr=($_ | Out-String)} | ConvertTo-Json -Compress
try { Send-JobCallback @{status='failed'; exit_code=1; completed=$true; message=$_.Exception.Message; log=($clientLog -join "`n"); stderr=($_ | Out-String)} } catch {}
exit 1
}
+8
View File
@@ -0,0 +1,8 @@
print("AssetManager test job")
print("JobId={{JobId}}")
print("AssetName={{AssetName}}")
print("Hostname={{Hostname}}")
print("IPAddress={{IPAddress}}")
print("Department={{Department}}")
print("Location={{Location}}")
print("MeshNodeId={{MeshNodeId}}")
+45
View File
@@ -0,0 +1,45 @@
import datetime
import json
import time
import traceback
import urllib.request
def callback(payload):
data = json.dumps(payload).encode("utf-8")
last_error = None
for attempt in range(1, 4):
request = urllib.request.Request(
"{{CallbackUrl}}",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
urllib.request.urlopen(request, timeout=60).read()
return
except Exception as exc:
last_error = exc
print(f"Callback attempt {attempt} of 3 failed: {exc}")
if attempt < 3:
time.sleep(2 * attempt)
raise RuntimeError(f"Callback failed after 3 attempts: {last_error}")
job_result = {}
try:
print(datetime.datetime.now(datetime.timezone.utc).isoformat(), "job={{JobId}} started")
# --- Eigene Befehle hier einfügen ---
# --- Ende eigene Befehle ---
payload = {"status": "success", "exit_code": 0, "completed": True, "message": "Job completed"}
if isinstance(job_result, dict):
payload.update(job_result)
callback(payload)
except Exception as exc:
try:
callback({"status": "failed", "exit_code": 1, "completed": True, "message": str(exc), "stderr": traceback.format_exc()})
finally:
raise
+65
View File
@@ -0,0 +1,65 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('ldap.title') }}</h1>
<p class="muted">{{ t('ldap.description') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/users">{{ t('ldap.back_to_users') }}</a>
</div>
</div>
<form method="post" action="/settings/ldap-search" class="panel">
<label>{{ t('ldap.search_label') }}
<input type="text" name="ldap_query" value="{{ query }}" placeholder="{{ t('ldap.search_placeholder') }}">
</label>
<div class="toolbar-actions">
<button type="submit">🔎 {{ t('ldap.query') }}</button>
<a class="button button-secondary" href="/settings/ldap-search?show_all=1">{{ t('ldap.show_all') }}</a>
</div>
<p class="muted">{{ t('ldap.search_help') }}</p>
</form>
{% if loaded %}
<form method="post" action="/settings/ldap-search/import" class="panel">
<div class="toolbar">
<h2>{{ t('ldap.users_found', count=results|length) }}</h2>
<div class="toolbar-actions">
<button type="submit"> {{ t('ldap.save_selected') }}</button>
</div>
</div>
<div class="table-scroll">
<table class="data-table">
<thead><tr>
<th class="selection-column" data-no-filter="1"><input type="checkbox" id="select-all-ldap"></th>
<th>{{ t('ldap.username') }}</th>
<th>{{ t('ldap.display_name') }}</th>
<th>{{ t('ldap.email') }}</th>
<th>{{ t('ldap.status') }}</th>
<th>{{ t('ldap.dn') }}</th>
</tr></thead>
<tbody>
{% for item in results %}
<tr>
<td class="selection-column"><input type="checkbox" class="ldap-select" name="usernames" value="{{ item.username }}" {% if item.existing and item.existing_source != 'ldap' %}disabled{% endif %}></td>
<td>{{ item.username }}</td>
<td>{{ item.display_name }}</td>
<td>{{ item.email }}</td>
<td>{% if item.existing %}{{ t('ldap.exists') }}{% else %}{{ t('ldap.can_save') }}{% endif %}</td>
<td>{{ item.dn }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
<script>
(() => {
const all = document.getElementById('select-all-ldap');
const boxes = [...document.querySelectorAll('.ldap-select:not(:disabled)')];
all?.addEventListener('change', () => boxes.forEach(box => box.checked = all.checked));
})();
</script>
{% endif %}
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% block content %}
<section class="auth-card">
<h1>{{ t('login.title') }}</h1>
<form method="post" action="/login">
<label>{{ t('login.username') }}<input name="username" autocomplete="username" required autofocus></label>
<label>{{ t('login.password') }}<input type="password" name="password" autocomplete="current-password" required></label>
<button type="submit">🔐 {{ t('login.submit') }}</button>
</form>
</section>
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{t('mesh.title')}}</h1><button id="sync-start">{{t('mesh.start')}}</button></div>
<div class="cards"><div class="card"><strong>{{linked}}</strong><span>{{t('mesh.linked_assets')}}</span></div><div class="card"><strong>{{conflicts}}</strong><span>{{t('mesh.conflicts')}}</span></div><div class="card"><strong>{{missing}}</strong><span>{{t('mesh.missing')}}</span></div></div>
<section class="panel"><div class="log-header"><h2>{{t('mesh.live_log')}}</h2><span id="sync-state" class="sync-status">{{t('mesh.ready')}}</span></div><div id="sync-log" class="sync-log"><div class="log-line log-info">{{t('mesh.ready_message')}}</div></div></section>
<section class="panel">
<h2>1. {{ t('mesh.connection') }}</h2>
<p class="muted">{{ t('mesh.password_env_help') }}</p>
<form method="post" action="/sync/meshcentral/settings" class="compact-form">
<div class="form-grid">
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="enabled" {% if config.meshcentral.enabled %}checked{% endif %}> {{ t('common.active') }}</label>
<label>WebSocket-URL<input name="url" value="{{config.meshcentral.url or ''}}" placeholder="wss://meshcentral.example.org:443"></label>
<label>{{ t('mesh.username') }}<input name="username" value="{{config.meshcentral.username or ''}}"></label>
<label>{{ t('mesh.password_env') }}<input name="password_env" value="{{config.meshcentral.password_env or 'MESHCENTRAL_PASSWORD'}}"></label>
<label>Tenant<input name="tenant" value="{{config.meshcentral.tenant or ''}}"></label>
<label>meshctrl.js-Pfad<input name="meshctrl_path" value="{{config.meshcentral.meshctrl_path}}"></label>
<label>{{ t('mesh.timeout_seconds') }}<input type="number" min="10" max="3600" name="timeout_seconds" value="{{config.meshcentral.timeout_seconds or 600}}"></label>
</div>
<div class="checkbox-row">
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="verify_tls" {% if config.meshcentral.verify_tls %}checked{% endif %}> {{ t('mesh.verify_tls') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="include_details" {% if config.meshcentral.get('include_details',true) %}checked{% endif %}> {{ t('mesh.include_details') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="create_missing_assets" {% if config.meshcentral.create_missing_assets %}checked{% endif %}> {{ t('mesh.create_missing_assets') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="mark_missing_devices" {% if config.meshcentral.mark_missing_devices %}checked{% endif %}> {{ t('mesh.mark_missing_devices') }}</label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="store_source_json" {% if config.meshcentral.get('store_source_json',true) %}checked{% endif %}> {{ t('mesh.store_source_json') }}</label>
</div>
<h3>{{ t('mesh.asset_matching') }}</h3><div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="node_id" {% if 'node_id' in config.meshcentral.match_order %}checked{% endif %}> Node-ID</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="match_order" value="serial_number" {% if 'serial_number' in config.meshcentral.match_order %}checked{% endif %}> Seriennummer</label></div>
<button class="button button-save">💾 {{ t('mesh.save_connection') }}</button>
</form>
</section>
<section class="panel">
<h2>2. {{ t('mesh.field_mappings') }}</h2>
<p class="muted">{{ t('mesh.mappings_help') }} {{ t('mesh.mappings_examples') }}: <code>node.osdesc</code>, <code>sys.hardware.windows.cpu[].Name</code>, <code>node.name|name</code>.</p>
<form method="post" action="/sync/meshcentral/mappings/save">
<div class="table-scroll management-table-scroll"><table class="data-table mesh-mapping-table"><thead><tr><th>{{ t('common.active') }}</th><th>{{ t('mesh.priority') }}</th><th>{{ t('mesh.target_field') }}</th><th>Source Path</th><th>{{ t('mesh.transformation') }}</th><th>{{ t('mesh.multi_values') }}</th><th>{{ t('mesh.separator') }}</th><th>{{ t('mesh.update_rule') }}</th><th>{{ t('common.description') }}</th><th></th></tr></thead><tbody>
{% for m in mappings %}
<tr>
<td><input type="checkbox" name="mapping_{{m.id}}_enabled" {% if m.enabled %}checked{% endif %}></td>
<td><input class="mapping-priority" type="number" name="mapping_{{m.id}}_priority" value="{{m.priority}}"></td>
<td data-filter-value="{% for d in definitions %}{% if d.field_name==m.target_field_name %}{{ d.label|e }} {{ d.field_name|e }} {{ 'System' if d.is_system else 'Custom' }}{% endif %}{% endfor %}"><select name="mapping_{{m.id}}_target_field_name">{% for d in definitions %}<option value="{{d.field_name}}" {% if d.field_name==m.target_field_name %}selected{% endif %}>{{d.label}} ({{d.field_name}}) {{'System' if d.is_system else 'Custom'}}</option>{% endfor %}</select>{% if m.is_system %}<small class="muted">{{ t('mesh.system_mapping') }}</small>{% else %}<small class="muted">{{ t('mesh.custom_mapping') }}</small>{% endif %}</td>
<td><input class="mapping-path" name="mapping_{{m.id}}_source_path" value="{{m.source_path}}" required></td>
<td data-filter-value="{{ m.transform|e }}"><select name="mapping_{{m.id}}_transform">{% for v in transform_options %}<option value="{{v}}" {% if m.transform==v %}selected{% endif %}>{{v}}</option>{% endfor %}</select></td>
<td><select name="mapping_{{m.id}}_multi_value_mode">{% for v in ['first','join','json','count'] %}<option value="{{v}}" {% if m.multi_value_mode==v %}selected{% endif %}>{{v}}</option>{% endfor %}</select></td>
<td><input class="mapping-separator" name="mapping_{{m.id}}_separator" value="{{m.separator|replace('\n','\\n')|replace('\t','\\t')}}"></td>
<td><select name="mapping_{{m.id}}_update_rule"><option value="fill_empty" {% if m.update_rule=='fill_empty' %}selected{% endif %}>{{ t('mesh.rule_fill_empty') }}</option><option value="meshcentral_wins" {% if m.update_rule=='meshcentral_wins' %}selected{% endif %}>{{ t('mesh.rule_mesh_wins') }}</option><option value="local_wins" {% if m.update_rule=='local_wins' %}selected{% endif %}>{{ t('mesh.rule_local_wins') }}</option></select></td>
<td><input name="mapping_{{m.id}}_description" value="{{m.description or ''}}"></td>
<td>{% if not m.is_system %}<button class="button danger" type="submit" formaction="/sync/meshcentral/mappings/{{m.id}}/delete" formmethod="post" onclick="return confirm({{ t('mesh.delete_mapping_confirm')|tojson }})">{{ t('common.delete') }}</button>{% endif %}</td>
</tr>
{% endfor %}
</tbody></table></div>
<button class="button button-save">💾 {{ t('mesh.save_mappings') }}</button>
</form>
</section>
<section class="panel">
<h2>3. {{ t('mesh.new_mapping') }}</h2>
<p class="muted">{{ t('mesh.new_mapping_help') }}</p>
<form method="post" action="/sync/meshcentral/mappings/new" class="compact-form">
<div class="form-grid">
<label>Zielfeld<select name="target_field_name" required><option value="">{{ t('common.select') }}</option>{% for d in definitions %}<option value="{{d.field_name}}">{{d.label}} ({{d.field_name}}) {{'System' if d.is_system else 'Custom'}}</option>{% endfor %}</select></label>
<label>Source Path<input name="source_path" required placeholder="node.example.path"></label>
<label>Transformation<select name="transform">{% for v in transform_options %}<option value="{{v}}">{{v}}</option>{% endfor %}</select></label>
<label>Mehrfachwerte<select name="multi_value_mode"><option value="first">first</option><option value="join">join</option><option value="json">json</option><option value="count">count</option></select></label>
<label>Trennzeichen<input name="separator" value="\n"></label>
<label>Update-Regel<select name="update_rule"><option value="fill_empty">{{ t('mesh.rule_fill_empty') }}</option><option value="meshcentral_wins">{{ t('mesh.rule_mesh_wins') }}</option><option value="local_wins">{{ t('mesh.rule_local_wins') }}</option></select></label>
<label>Beschreibung<input name="description"></label>
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="enabled" checked> {{ t('common.active') }}</label>
</div><button class="button button-save"> {{ t('mesh.create_mapping') }}</button>
</form>
</section>
<section class="panel"><h2>{{t('mesh.recent_runs')}}</h2><table><thead><tr><th>{{t('mesh.start_time')}}</th><th>{{t('assets.status')}}</th><th>{{t('mesh.found')}}</th><th>{{t('mesh.created')}}</th><th>{{t('mesh.changed')}}</th><th>{{t('mesh.conflicts')}}</th><th>{{t('mesh.errors')}}</th><th>{{t('mesh.message')}}</th></tr></thead><tbody>{% for run in runs %}<tr><td><time class="local-time" datetime="{{ utc_iso(run.started_at) }}" data-utc="{{ utc_iso(run.started_at) }}">{{ run.started_at }}</time></td><td>{{run.status}}</td><td>{{run.devices_found}}</td><td>{{run.created_count}}</td><td>{{run.updated_count}}</td><td>{{run.conflict_count}}</td><td>{{run.error_count}}</td><td>{{run.message or ''}}</td></tr>{% else %}<tr><td colspan="8">{{t('mesh.no_runs')}}</td></tr>{% endfor %}</tbody></table></section>
<script>(()=>{const button=document.getElementById('sync-start'),logBox=document.getElementById('sync-log'),state=document.getElementById('sync-state');let offset=0,timer=null;function addLog(e){const l=document.createElement('div');l.className=`log-line log-${e.level||'info'}`;const localTime=window.AssetManagerTime?.formatUtc(e.time,e.time||'--')||(e.time||'--');l.textContent=`[${localTime}] ${e.message}`;logBox.appendChild(l);logBox.scrollTop=logBox.scrollHeight}async function jsonResponse(response,fallback){const text=await response.text();let data;try{data=JSON.parse(text)}catch(_){throw new Error(`${fallback} (HTTP ${response.status}): ${text.slice(0,300)||'{{ t('mesh.empty_response') }}'}`)}if(!response.ok)throw new Error(data.error||data.detail||fallback);return data}async function poll(id){try{const r=await fetch(`/sync/meshcentral/jobs/${id}?offset=${offset}`,{cache:'no-store'}),d=await jsonResponse(r,'{{ t('mesh.log_load_error') }}');d.logs.forEach(addLog);offset=d.next_offset;state.textContent=d.status;if(d.finished){clearInterval(timer);button.disabled=false}}catch(e){clearInterval(timer);button.disabled=false;addLog({level:'error',message:e.message})}}button.onclick=async()=>{button.disabled=true;logBox.innerHTML='';offset=0;try{const r=await fetch('/sync/meshcentral/start',{method:'POST'}),d=await jsonResponse(r,'{{ t('mesh.start_error') }}');await poll(d.job_id);timer=setInterval(()=>poll(d.job_id),800)}catch(e){addLog({level:'error',message:e.message});button.disabled=false}}})();</script>
{% endblock %}
+87
View File
@@ -0,0 +1,87 @@
{% extends 'base.html' %}
{% block content %}
<style>
.profile-layout{display:grid;grid-template-columns:minmax(420px,720px) minmax(320px,460px);gap:1.25rem;align-items:start;max-width:1100px;margin:0}
.profile-layout>.panel,.profile-settings,.profile-settings .auth-card{width:100%;max-width:none;margin:0;box-sizing:border-box}
.profile-settings{display:grid;gap:1.25rem;align-content:start}.profile-settings form{min-width:0}
@media(max-width:900px){.profile-layout{grid-template-columns:1fr;max-width:760px;margin:0}.profile-layout dl{grid-template-columns:minmax(150px,220px) 1fr}}
@media(max-width:600px){.profile-layout dl{grid-template-columns:1fr}.profile-layout dt{border-bottom:0;padding-bottom:2px}.profile-layout dd{padding-top:2px}}
</style>
<div class="toolbar">
<h1>{{ t('profile.title') }}</h1>
<a class="button button-secondary" href="/logout">{{ t('app.logout') }}</a>
</div>
<div class="profile-layout">
<div class="panel profile-panel">
<dl>
<dt>{{ t('profile.username') }}</dt><dd>{{ user.username }}</dd>
<dt>{{ t('profile.display_name') }}</dt><dd>{{ user.display_name or '' }}</dd>
<dt>{{ t('profile.email') }}</dt><dd>{{ user.email or '' }}</dd>
<dt>{{ t('profile.source') }}</dt><dd>{{ user.auth_source }}</dd>
<dt>{{ t('profile.last_login') }}</dt><dd>{% if user.last_login %}<time class="local-time" datetime="{{ utc_iso(user.last_login) }}" data-utc="{{ utc_iso(user.last_login) }}">{{ user.last_login }}</time>{% else %}{{ '' }}{% endif %}</dd>
</dl>
</div>
<div class="profile-settings">
<form method="post" action="/profile/language" class="auth-card">
<h2>{{ t('common.language') }}</h2>
<input type="hidden" name="next_url" value="/profile">
<label>
{{ t('common.language') }}
<select name="language_code">
{% for language in languages %}
<option value="{{ language.code }}" {% if language.code == user.language_code %}selected{% endif %}>{{ language.native_name }}</option>
{% endfor %}
</select>
</label>
<button type="submit">🌐 {{ t('profile.save_language') }}</button>
</form>
<form method="post" action="/profile/theme" class="auth-card">
<h2>{{ t('appearance.theme') }}</h2>
<p class="table-hint">{{ t('appearance.profile_help') }}</p>
<input type="hidden" name="next_url" value="/profile">
<div class="theme-mode-choice">
<label class="theme-choice-card">
<input type="radio" name="theme_mode" value="light" {% if user.theme_mode != 'dark' %}checked{% endif %}>
<span class="theme-choice-preview theme-choice-preview-light" aria-hidden="true"><span></span><span></span></span>
<strong>☀ {{ t('appearance.light') }}</strong>
</label>
<label class="theme-choice-card">
<input type="radio" name="theme_mode" value="dark" {% if user.theme_mode == 'dark' %}checked{% endif %}>
<span class="theme-choice-preview theme-choice-preview-dark" aria-hidden="true"><span></span><span></span></span>
<strong>☾ {{ t('appearance.dark') }}</strong>
</label>
</div>
<button type="submit" class="button button-save" data-save-shortcut>💾 {{ t('common.save') }}</button>
</form>
<form method="post" action="/profile/browser-state" class="auth-card"><h2>{{ t('profile.browser_storage') }}</h2><p class="table-hint">{{ t('profile.browser_storage_help') }}</p><label class="checkbox-label"><input type="checkbox" name="persist_browser_state" {% if user.persist_browser_state %}checked{% endif %}>{{ t('profile.browser_storage_enable') }}</label><button type="submit" class="button button-save" data-save-shortcut>💾 {{ t('profile.save_browser_storage') }}</button></form>
<form method="post" action="/profile/toast-settings" class="auth-card">
<h2>{{ t('profile.toast_settings') }}</h2>
<label>{{ t('profile.toast_position') }}
<select name="toast_position">
{% for value, label_key in [('top-left','profile.toast_top_left'),('top-center','profile.toast_top_center'),('top-right','profile.toast_top_right'),('bottom-left','profile.toast_bottom_left'),('bottom-center','profile.toast_bottom_center'),('bottom-right','profile.toast_bottom_right')] %}
<option value="{{ value }}" {% if user.toast_position == value %}selected{% endif %}>{{ t(label_key) }}</option>
{% endfor %}
</select>
</label>
<label>{{ t('profile.toast_duration') }}
<input type="number" name="toast_duration_seconds" min="1" max="120" step="1" value="{{ user.toast_duration_seconds or 6 }}" required>
</label>
<button type="submit" class="button button-save" data-save-shortcut>💾 {{ t('common.save') }}</button>
</form>
{% if user.auth_source == 'local' %}
<form method="post" action="/profile/password" class="auth-card">
<h2>{{ t('profile.change_password') }}</h2>
<label>{{ t('profile.current_password') }}<input type="password" name="current_password" required></label>
<label>{{ t('profile.new_password') }}<input type="password" name="new_password" minlength="8" required></label>
<button type="submit">🔑 {{ t('profile.change_password') }}</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}
+260
View File
@@ -0,0 +1,260 @@
{% extends 'base.html' %}
{% block content %}
{% set appinfo = application_info() %}
<div class="toolbar"><h1>{{ t("app.settings") }}</h1></div>
<form method="post" action="/settings" data-active-settings-section="{{ settings_section }}" enctype="multipart/form-data" class="settings-form">
<section class="settings-section" data-settings-section="appearance">
<h2>{{ t("settings.appearance") }}</h2>
<label>{{ t("settings.application_title") }}<input type="text" name="title" value="{{ settings.general.title or 'AssetManager' }}" required></label>
<label>{{ t("settings.company_name") }}<input type="text" name="company_name" value="{{ settings.general.company_name or '' }}"></label>
<label>{{ t("settings.company_logo") }}<input type="file" name="logo" accept=".png,.jpg,.jpeg,.webp,.gif"></label>
{% if settings.general.logo %}<div class="logo-preview"><img src="{{ settings.general.logo }}" alt="{{ t('settings.current_logo') }}"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="remove_logo"> {{ t('settings.remove_logo') }}</label></div>{% endif %}
<label>{{ t('settings.favicon', 'Browser-Tab-Logo (Favicon)') }}
<input type="file" name="favicon" accept=".png,.jpg,.jpeg,.webp,.gif">
<small>{{ t('settings.favicon_hint', 'Empfohlen: quadratisches PNG, zum Beispiel 32 × 32 oder 64 × 64 Pixel.') }}</small>
</label>
{% if settings.general.favicon %}
<div class="logo-preview favicon-preview">
<img src="{{ settings.general.favicon }}" alt="{{ t('settings.current_favicon', 'Aktuelles Browser-Tab-Logo') }}">
<label class="checkbox-label"><input class="checkbox" type="checkbox" name="remove_favicon"> {{ t('settings.remove_favicon', 'Browser-Tab-Logo entfernen') }}</label>
</div>
{% endif %}
<label>{{ t("common.language") }}<select name="default_language">{% for language in languages %}<option value="{{ language.code }}" {% if language.code == settings.general.get("default_language", "en") %}selected{% endif %}>{{ language.native_name }}</option>{% endfor %}</select></label>
<label>{{ t('appearance.default_theme') }}
<select name="default_theme">
<option value="light" {% if settings.general.get('default_theme', 'light') == 'light' %}selected{% endif %}>{{ t('appearance.light') }}</option>
<option value="dark" {% if settings.general.get('default_theme', 'light') == 'dark' %}selected{% endif %}>{{ t('appearance.dark') }}</option>
</select>
</label>
</section>
{% set theme_colors = settings.general.get('colors', {}) %}
<section class="settings-section appearance-color-section" data-settings-section="appearance">
<div class="settings-section-heading">
<div><h2>{{ t('appearance.colors_title') }}</h2><p class="muted">{{ t('appearance.colors_help') }}</p></div>
<button type="button" class="button button-secondary" id="reset-theme-colors">{{ t('appearance.reset_colors') }}</button>
</div>
<div class="theme-color-layout">
<div class="theme-color-grid">
{% set color_fields = [
('primary_color','appearance.primary_color','#0878bd'),
('primary_hover_color','appearance.primary_hover_color','#075f94'),
('header_color','appearance.header_color','#063c66'),
('navigation_color','appearance.navigation_color','#ffffff'),
('page_background','appearance.page_background','#f2f4f6'),
('surface_color','appearance.surface_color','#ffffff'),
('text_color','appearance.text_color','#1f2933'),
('border_color','appearance.border_color','#d7dde3'),
('success_color','appearance.success_color','#198754'),
('warning_color','appearance.warning_color','#d97706'),
('danger_color','appearance.danger_color','#b42318')
] %}
{% for name, label_key, default in color_fields %}
<label class="theme-color-field">
<span>{{ t(label_key) }}</span>
<span class="theme-color-input">
<input type="color" name="{{ name }}" value="{{ theme_colors.get(name, default) }}" data-default-color="{{ default }}">
<code>{{ theme_colors.get(name, default) }}</code>
</span>
</label>
{% endfor %}
</div>
<div class="theme-live-preview" id="theme-live-preview">
<h3>{{ t('appearance.color_preview') }}</h3>
<div class="theme-preview-window">
<div class="theme-preview-header"></div>
<div class="theme-preview-body">
<div class="theme-preview-nav"></div>
<div class="theme-preview-content">
<div class="theme-preview-card"></div>
<div class="theme-preview-buttons"><i></i><i></i><i></i></div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="settings-section" data-settings-section="system">
<h2>{{ t('settings.system_information') }}</h2>
<p class="muted">{{ t('settings.system_information_file_help') }}</p>
<dl class="system-info-grid">
<dt>{{ t('settings.appinfo_path') }}</dt><dd><code>/app/config/APPINFO.json</code></dd>
<dt>{{ t('about.version') }}</dt><dd>{{ application_version() }}</dd>
<dt>{{ t('about.author') }}</dt><dd>{{ appinfo.author or '—' }}</dd>
<dt>{{ t('about.organization') }}</dt><dd>{{ appinfo.organization or '—' }}</dd>
<dt>{{ t('about.contact') }}</dt><dd>{{ appinfo.contact or '—' }}</dd>
<dt>{{ t('about.website') }}</dt><dd>{% if appinfo.website %}<a href="{{ appinfo.website }}" target="_blank" rel="noopener">{{ appinfo.website }}</a>{% else %}—{% endif %}</dd>
<dt>{{ t('about.repository') }}</dt><dd>{% if appinfo.repository %}<a href="{{ appinfo.repository }}" target="_blank" rel="noopener">{{ appinfo.repository }}</a>{% else %}—{% endif %}</dd>
<dt>{{ t('about.license') }}</dt><dd>{{ appinfo.license or '—' }}</dd>
<dt>{{ t('about.copyright') }}</dt><dd>{{ appinfo.copyright or '—' }}</dd>
</dl>
<h3>{{ t('settings.storage_information', 'Speicher und Laufzeit') }}</h3>
<p class="muted">{{ t('settings.storage_information_help', 'Die folgenden Pfade und Zustände werden zur Laufzeit im Container ermittelt.') }}</p>
<dl class="system-info-grid">
<dt>{{ t('settings.database', 'Datenbank') }}</dt>
<dd><span class="status-badge status-success">✓ {{ t('settings.database_connected', 'PostgreSQL verbunden') }}</span></dd>
<dt>{{ t('settings.config_directory', 'Konfigurationsverzeichnis') }}</dt>
<dd><code>{{ system_storage.config.path }}</code></dd>
<dt>{{ t('settings.upload_directory', 'Uploadverzeichnis') }}</dt>
<dd><code>{{ system_storage.uploads.path }}</code></dd>
<dt>{{ t('settings.log_directory', 'Logverzeichnis') }}</dt>
<dd><code>{{ system_storage.logs.path }}</code></dd>
<dt>{{ t('settings.backup_directory', 'Backupverzeichnis') }}</dt>
<dd><code>{{ system_storage.backups.path }}</code></dd>
<dt>{{ t('settings.backup_writable', 'Backupverzeichnis beschreibbar') }}</dt>
<dd>
{% if system_storage.backups.writable %}
<span class="status-badge status-success">✓ {{ t('common.yes', 'Ja') }}</span>
{% else %}
<span class="status-badge status-danger">✕ {{ t('common.no', 'Nein') }}</span>
{% if system_storage.backups.error %}<br><small class="muted">{{ system_storage.backups.error }}</small>{% endif %}
{% endif %}
</dd>
<dt>{{ t('settings.backup_mount_detected', 'Eigenes Docker-Mount erkannt') }}</dt>
<dd>{% if system_storage.backups.is_mount %}✓ {{ t('common.yes', 'Ja') }}{% else %}— {{ t('common.no', 'Nein') }}{% endif %}</dd>
<dt>{{ t('settings.latest_backup', 'Letztes Backup') }}</dt>
<dd>
{% if system_storage.latest_backup %}
<time class="local-time" datetime="{{ system_storage.latest_backup.modified_at.isoformat() }}" data-utc="{{ system_storage.latest_backup.modified_at.isoformat() }}">{{ system_storage.latest_backup.modified_at.strftime('%Y-%m-%d %H:%M:%S') }}</time>
{% else %}—{% endif %}
</dd>
<dt>{{ t('settings.backup_count', 'Anzahl vorhandener Backups') }}</dt>
<dd>{{ system_storage.backup_count }}</dd>
<dt>{{ t('settings.backup_storage_used', 'Belegter Backup-Speicher') }}</dt>
<dd>{{ '%.2f'|format(system_storage.backup_total_size / 1024 / 1024) }} MB</dd>
</dl>
</section>
<section class="settings-section" data-settings-section="appearance">
<h2>{{ t('settings.custom_css') }}</h2>
<p class="muted">{{ t('settings.custom_css_help') }}</p>
<label for="custom-css-editor">{{ t('settings.custom_css') }}
<textarea id="custom-css-editor" name="custom_css" class="custom-css-editor" rows="18" spellcheck="false" placeholder="/* Beispiel */
.inventory-tab { color: #000; }">{{ settings.general.get('custom_css', '') }}</textarea>
</label>
</section>
<section class="settings-section" data-settings-section="meshcentral">
<h2>{{ t('settings.mesh_mapping') }}</h2>
{% set mesh = settings.meshcentral %}
{% set mapping = mesh.get('category_mapping', {}) %}
<p class="muted">{{ t('settings.mesh_mapping_help') }}</p>
<label>{{ t('settings.fallback_category') }}
<select name="mesh_fallback_category_id" required>
{% for category in categories %}
<option value="{{ category.id }}" {% if category.id == (mesh.get('fallback_category_id', 1)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
{% endfor %}
</select>
</label>
<div class="form-grid">
{% set mesh_types = [
('1', 'settings.mesh_type_desktop'),
('2', 'settings.mesh_type_notebook'),
('3', 'settings.mesh_type_mobile'),
('4', 'settings.mesh_type_server'),
('5', 'settings.mesh_type_5'),
('6', 'settings.mesh_type_6'),
('7', 'settings.mesh_type_7'),
('8', 'settings.mesh_type_linux_other')
] %}
{% for type_id, type_label in mesh_types %}
<label>{{ t(type_label) }} ({{ t('settings.meshcentral_type') }} {{ type_id }})
<select name="mesh_category_{{ type_id }}">
<option value="">{{ t('settings.use_fallback') }}</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if mapping.get(type_id) is not none and category.id == (mapping.get(type_id)|int) %}selected{% endif %}>{{ category.name }} (ID {{ category.id }})</option>
{% endfor %}
</select>
</label>
{% endfor %}
</div>
</section>
<section class="settings-section" data-settings-section="authentication">
<h2>{{ t('settings.authentication') }}</h2>
<p class="muted">{{ t('settings.auth_help') }}</p>
<label>{{ t('settings.auth_mode') }}
<select name="auth_mode" id="auth-mode">
<option value="none" {% if settings.authentication.mode == 'none' %}selected{% endif %}>{{ t('settings.auth_none') }}</option>
<option value="local" {% if settings.authentication.mode == 'local' %}selected{% endif %}>{{ t('settings.auth_local') }}</option>
<option value="ldap" {% if settings.authentication.mode == 'ldap' %}selected{% endif %}>LDAP / Active Directory</option>
</select>
</label>
{% set ldap = settings.authentication.get('ldap', {}) %}
<div id="ldap-settings" class="settings-subsection">
<h3>{{ t('settings.ldap_connection') }}</h3>
<div class="form-grid">
<label>{{ t('settings.ldap_server') }}<input type="text" name="ldap_server" value="{{ ldap.get('server', '') }}" placeholder="dc01.example.org"></label>
<label>{{ t('settings.port') }}<input type="number" name="ldap_port" value="{{ ldap.get('port', 389) }}" min="1" max="65535"></label>
</div>
<div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="ldap_use_ssl" {% if ldap.get('use_ssl') %}checked{% endif %}> {{ t('settings.use_ssl') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="ldap_start_tls" {% if ldap.get('start_tls') %}checked{% endif %}> {{ t('settings.use_starttls') }}</label></div>
<label>{{ t('settings.bind_user') }}<input type="text" name="ldap_bind_dn" value="{{ ldap.get('bind_dn', '') }}" placeholder="svc_assetmanager@example.org"></label>
<label>{{ t('settings.bind_password_env') }}<input type="text" name="ldap_bind_password_env" value="{{ ldap.get('bind_password_env', 'LDAP_BIND_PASSWORD') }}"></label>
<label>{{ t('settings.user_base_dn') }}<input type="text" name="ldap_user_base_dn" value="{{ ldap.get('user_base_dn', '') }}" placeholder="DC=example,DC=org"></label>
<label>{{ t('settings.user_filter') }}<input type="text" name="ldap_user_filter" value="{{ ldap.get('user_filter', '(sAMAccountName={username})') }}"></label>
<div class="form-grid"><label>{{ t('settings.display_name_attribute') }}<input type="text" name="ldap_display_name_attribute" value="{{ ldap.get('display_name_attribute', 'displayName') }}"></label><label>{{ t('settings.email_attribute') }}<input type="text" name="ldap_email_attribute" value="{{ ldap.get('email_attribute', 'mail') }}"></label></div>
<p class="muted">{{ t('settings.bind_password_help') }}</p><p><a class="button button-secondary" href="/settings/ldap-search">🔎 {{ t('ldap.title') }}</a></p>
</div>
</section>
<button type="submit" class="button button-save settings-save-button">💾 {{ t("settings.save") }}</button>
</form>
<script>
(() => { const mode=document.getElementById('auth-mode'); const ldap=document.getElementById('ldap-settings'); const update=()=>ldap.hidden=mode.value!=='ldap'; mode.addEventListener('change',update); update(); })();
</script>
<script>
(() => {
const form = document.querySelector('[data-active-settings-section]');
if (!form) return;
const active = form.dataset.activeSettingsSection || 'appearance';
form.querySelectorAll('[data-settings-section]').forEach(section => {
section.hidden = section.dataset.settingsSection !== active;
});
const save = form.querySelector('.settings-save-button');
if (save) save.hidden = active === 'system';
})();
</script>
<script>
(() => {
const fields = [...document.querySelectorAll('.theme-color-field input[type="color"]')];
const reset = document.getElementById('reset-theme-colors');
const variableNames = {
primary_color: '--preview-primary',
primary_hover_color: '--preview-primary-hover',
header_color: '--preview-header',
navigation_color: '--preview-navigation',
page_background: '--preview-page-bg',
surface_color: '--preview-surface',
text_color: '--preview-text',
border_color: '--preview-border',
success_color: '--preview-success',
warning_color: '--preview-warning',
danger_color: '--preview-danger'
};
const preview = document.getElementById('theme-live-preview');
function refresh() {
fields.forEach(input => {
input.nextElementSibling.textContent = input.value.toUpperCase();
preview?.style.setProperty(variableNames[input.name], input.value);
});
}
fields.forEach(input => input.addEventListener('input', refresh));
reset?.addEventListener('click', () => {
fields.forEach(input => { input.value = input.dataset.defaultColor; });
refresh();
});
refresh();
})();
</script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends 'base.html' %}
{% block content %}
<div class="backup-page">
<div class="backup-page-header">
<div>
<h1>{{ t('backup.title', 'Backups') }}</h1>
<p class="muted">{{ t('backup.description', 'Create, download, upload and restore complete AssetManager backups.') }}</p>
</div>
<form method="post" action="/settings/backup/create" class="backup-create-form">
<button type="submit" class="button backup-primary-action">{{ t('backup.create_now', 'Create backup now') }}</button>
</form>
</div>
<div class="backup-summary-grid">
<section class="backup-summary-card">
<span class="backup-summary-icon" aria-hidden="true">📁</span>
<div><span class="backup-summary-label">{{ t('backup.directory', 'Backup directory') }}</span><strong><code>{{ backup_directory }}</code></strong></div>
</section>
<section class="backup-summary-card">
<span class="backup-summary-icon" aria-hidden="true">🕒</span>
<div><span class="backup-summary-label">{{ t('backup.interval', 'Interval') }}</span><strong>{{ t('backup.every_hours', 'Every {hours} hours', hours=backup_interval_hours) }}</strong></div>
</section>
<section class="backup-summary-card">
<span class="backup-summary-icon" aria-hidden="true">🗓️</span>
<div><span class="backup-summary-label">{{ t('backup.retention', 'Retention') }}</span><strong>{{ t('backup.days', '{days} days', days=backup_retention_days) }}</strong></div>
</section>
</div>
<section class="panel backup-panel">
<div class="backup-panel-heading">
<div><h2>{{ t('backup.automatic_title', 'Automatic backup') }}</h2><p class="muted">{{ t('backup.automatic_help', 'Backups are created automatically according to the configured interval and deleted after the retention period.') }}</p></div>
<span class="backup-status-badge">{{ t('backup.active', 'Active') }}</span>
</div>
<div class="backup-content-note"><span aria-hidden="true"></span><p>{{ t('backup.contents_help', 'Each backup contains a PostgreSQL database dump, configuration, APPINFO and uploaded files. Environment variables and the .env file are not included.') }}</p></div>
</section>
<section class="panel backup-panel">
<div class="backup-panel-heading"><div><h2>{{ t('backup.upload_title', 'Upload backup') }}</h2><p class="muted">{{ t('backup.upload_help', 'Upload an AssetManager backup ZIP to make it available for download or restoration.') }}</p></div></div>
<form method="post" action="/settings/backup/upload" enctype="multipart/form-data" class="backup-upload-form">
<input type="file" name="backup_file" accept=".zip,application/zip" required>
<button type="submit" class="button-secondary">{{ t('backup.upload', 'Upload') }}</button>
</form>
</section>
<section class="panel backup-panel">
<div class="backup-panel-heading"><div><h2>{{ t('backup.available', 'Available backups') }}</h2><p class="muted">{{ t('backup.available_help', 'Stored backups can be downloaded, restored or deleted here.') }}</p></div><span class="backup-count">{{ backups|length }}</span></div>
{% if backups %}
<div class="table-scroll backup-table-wrap"><table class="data-table backup-table"><thead><tr>
<th>{{ t('backup.file', 'File') }}</th><th>{{ t('backup.created', 'Created') }}</th><th>{{ t('backup.version', 'Version') }}</th><th>{{ t('backup.reason', 'Reason') }}</th><th>{{ t('backup.size', 'Size') }}</th><th>{{ t('common.actions', 'Actions') }}</th>
</tr></thead><tbody>{% for backup in backups %}<tr>
<td><code>{{ backup.name }}</code>{% if not backup.valid %}<br><span class="muted">{{ t('backup.invalid_metadata', 'Metadata could not be read') }}</span>{% endif %}</td>
<td><time class="local-time" datetime="{{ backup.modified_at.isoformat() }}" data-utc="{{ backup.modified_at.isoformat() }}">{{ backup.modified_at.strftime('%Y-%m-%d %H:%M:%S') }}</time></td><td>{{ backup.version if backup.version else t('backup.version_unknown') }}</td><td>{{ t('backup.reason_' ~ (backup.reason or 'unknown'), backup.reason or '—') }}</td><td>{{ '%.2f'|format(backup.size / 1024 / 1024) }} MB</td>
<td><div class="backup-actions"><a class="button button-secondary" href="/settings/backup/{{ backup.name }}/download">{{ t('backup.download', 'Download') }}</a><button type="button" class="button-secondary" onclick="document.getElementById('restore-{{ loop.index }}').showModal()">{{ t('backup.restore', 'Restore') }}</button><form method="post" action="/settings/backup/{{ backup.name }}/delete" onsubmit="return confirm('{{ t('backup.delete_confirm', 'Delete this backup permanently?')|e }}')"><button type="submit" class="button-danger">{{ t('common.delete', 'Delete') }}</button></form></div>
<dialog id="restore-{{ loop.index }}" class="backup-dialog"><form method="post" action="/settings/backup/{{ backup.name }}/restore"><h2>{{ t('backup.restore_title', 'Restore backup') }}</h2><p>{{ t('backup.restore_warning', 'The current database, configuration and uploads will be replaced. A safety backup is created immediately before restoration.') }}</p><label>{{ t('backup.restore_confirmation', 'Enter RESTORE to confirm') }}<input name="confirmation" autocomplete="off" required></label><div class="form-actions"><button type="button" class="button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel', 'Cancel') }}</button><button type="submit" class="button-danger">{{ t('backup.restore_now', 'Restore now') }}</button></div></form></dialog></td>
</tr>{% endfor %}</tbody></table></div>
{% else %}<div class="backup-empty-state"><span aria-hidden="true">🗄️</span><strong>{{ t('backup.none_title', 'No backups yet') }}</strong><p class="muted">{{ t('backup.none', 'No backups are available yet.') }}</p></div>{% endif %}
</section>
</div>
{% endblock %}
@@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('settings.backup', 'Sicherung') }}</h1>
</div>
</div>
<section class="panel">
<h2>{{ t('settings.backup_planned', 'Datenbanksicherung ist für v0.3.15.0 vorgesehen') }}</h2>
<p>{{ t('settings.backup_planned_help', 'Die automatische Sicherung alle 8 Stunden, eine Aufbewahrung von 3 Tagen sowie Download, Upload, manuelles Backup und Wiederherstellung werden im nächsten Entwicklungsschritt umgesetzt.') }}</p>
<p><strong>{{ t('settings.backup_directory', 'Backup-Verzeichnis') }}:</strong> <code>/app/data/backups</code></p>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('settings.import_export', 'Import / Export') }}</h1>
<p class="muted">{{ t('settings.import_export_help', 'Assets als Excel-Datei exportieren oder aus einer Excel-Datei importieren.') }}</p>
</div>
</div>
<div class="settings-action-grid">
<section class="panel">
<h2>{{ t('assets.import', 'Import') }}</h2>
<p>{{ t('settings.import_help', 'Vorhandene Assets über die Asset-ID aktualisieren und neue Assets ohne Asset-ID anlegen.') }}</p>
<div class="form-actions">
<a class="button" href="/assets/import">{{ t('assets.import_open', 'Import öffnen') }}</a>
<a class="button button-secondary" href="/assets/import/template.xlsx">{{ t('assets.import_template', 'Importvorlage herunterladen') }}</a>
</div>
</section>
<section class="panel">
<h2>{{ t('assets.export', 'Export') }}</h2>
<p>{{ t('settings.export_help', 'Den aktuellen Assetbestand mit Asset-ID und allen exportierbaren Feldern herunterladen.') }}</p>
<div class="form-actions">
<a class="button" href="/export/assets.xlsx">{{ t('assets.export_download', 'Assets als Excel herunterladen') }}</a>
</div>
</section>
</div>
{% endblock %}
+341
View File
@@ -0,0 +1,341 @@
{% extends 'base.html' %}
{% block content %}
<div class="software-settings-page">
<div class="toolbar software-settings-header">
<div>
<h1>{{ t('software.settings.title') }}</h1>
<p class="muted">{{ t('software.settings.description') }}</p>
</div>
<a class="button button-secondary" href="/software">{{ t('software.settings.back_to_software') }}</a>
</div>
<form method="post" action="/settings/software" class="software-settings-form">
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.dispatch_title') }}</h2>
<p class="muted">{{ t('software.settings.dispatch_help') }}</p>
</div>
<span class="software-settings-badge">{{ t('software.settings.dispatch_badge') }}</span>
</div>
<div class="software-settings-grid">
<label>
{{ t('software.settings.dispatch_delay') }}
<input type="number" name="dispatch_delay_seconds" min="0" max="60" step="1" value="{{ software_settings.get('dispatch_delay_seconds', 2) }}">
<small>{{ t('software.settings.dispatch_delay_help') }}</small>
</label>
<div class="software-callback-preview">
<span>{{ t('software.settings.timeout_check') }}</span>
<code>{{ t('software.settings.timeout_check_value') }}</code>
</div>
</div>
</section>
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.retry_title') }}</h2>
<p class="muted">{{ t('software.settings.retry_help') }}</p>
</div>
<span class="software-settings-badge">{{ t('software.settings.retry_badge') }}</span>
</div>
<div class="software-settings-grid">
<label class="software-toggle-option software-settings-wide">
<input type="checkbox" name="automatic_retry_enabled" {% if software_settings.get('automatic_retry_enabled', true) %}checked{% endif %}>
<span class="software-toggle-track"><span class="software-toggle-thumb"></span></span>
<span>
<strong>{{ t('software.settings.retry_enabled') }}</strong>
<small>{{ t('software.settings.retry_enabled_help') }}</small>
</span>
</label>
<label>
{{ t('software.settings.retry_max_age') }}
<input type="number" name="automatic_retry_max_age_hours" min="1" max="168" step="1" value="{{ software_settings.get('automatic_retry_max_age_hours', 12) }}">
<small>{{ t('software.settings.retry_max_age_help') }}</small>
</label>
<label>
{{ t('software.settings.retry_interval') }}
<input type="number" name="automatic_retry_interval_seconds" min="10" max="86400" step="1" value="{{ software_settings.get('automatic_retry_interval_seconds', 60) }}">
<small>{{ t('software.settings.retry_interval_help') }}</small>
</label>
<label>
{{ t('software.settings.retry_max_retries') }}
<input type="number" name="automatic_retry_max_retries" min="1" max="10" step="1" value="{{ software_settings.get('automatic_retry_max_retries', 3) }}">
<small>{{ t('software.settings.retry_max_retries_help') }}</small>
</label>
<div class="software-callback-preview">
<span>{{ t('software.settings.retry_statuses') }}</span>
<code>{{ t('software.settings.retry_statuses_value') }}</code>
</div>
</div>
</section>
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.callback_title') }}</h2>
<p class="muted">{{ t('software.settings.callback_help') }}</p>
</div>
<span class="software-settings-badge">{{ t('software.settings.callback_badge') }}</span>
</div>
<div class="software-settings-grid">
<label class="software-settings-wide">
{{ t('software.settings.callback_base_url') }}
<input
type="url"
name="callback_base_url"
value="{{ software_settings.get('callback_base_url', '') }}"
placeholder="https://assetmanager.example.org"
autocomplete="url"
>
<small>{{ t('software.settings.callback_base_url_help') }}</small>
</label>
<label>
{{ t('software.settings.callback_timeout') }}
<input type="number" name="callback_timeout_minutes" min="5" max="240" value="{{ software_settings.get('callback_timeout_minutes', 30) }}">
<small>{{ t('software.settings.callback_timeout_help') }}</small>
</label>
<label>
{{ t('software.settings.callback_workers') }}
<input type="number" name="callback_worker_count" min="1" max="8" value="{{ software_settings.get('callback_worker_count', 4) }}">
<small>{{ t('software.settings.callback_workers_help') }}</small>
</label>
<label>
{{ t('software.settings.test_timeout') }}
<input type="number" name="callback_test_timeout_seconds" min="2" max="60" value="{{ software_settings.get('callback_test_timeout_seconds', 10) }}">
<small>{{ t('software.settings.test_timeout_help') }}</small>
</label>
<label class="software-toggle-option">
<input type="checkbox" name="callback_test_verify_tls" {% if software_settings.get('callback_test_verify_tls', true) %}checked{% endif %}>
<span class="software-toggle-track"><span class="software-toggle-thumb"></span></span>
<span>
<strong>{{ t('software.settings.verify_tls') }}</strong>
<small>{{ t('software.settings.verify_tls_help') }}</small>
</span>
</label>
<div class="software-callback-preview software-settings-wide">
<span>{{ t('software.settings.health_url') }}</span>
<code id="callback-health-preview">{{ callback_health_url or t('software.settings.automatic_url') }}</code>
</div>
</div>
</section>
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.debug_title') }}</h2>
<p class="muted">{{ t('software.settings.debug_help') }}</p>
</div>
</div>
<div class="software-settings-grid">
<label class="software-toggle-option">
<input type="checkbox" name="debug_logging" {% if software_settings.get('debug_logging', true) %}checked{% endif %}>
<span class="software-toggle-track"><span class="software-toggle-thumb"></span></span>
<span>
<strong>{{ t('software.settings.debug_logging') }}</strong>
<small>{{ t('software.settings.debug_logging_help') }}</small>
</span>
</label>
<label>
{{ t('software.settings.debug_max_lines') }}
<input type="number" name="debug_log_max_lines" min="100" max="10000" value="{{ software_settings.get('debug_log_max_lines', 1000) }}">
<small>{{ t('software.settings.debug_max_lines_help') }}</small>
</label>
<div class="software-debug-path software-settings-wide">
<span>{{ t('software.settings.debug_log_path') }}</span>
<code>{{ debug_log_path }}</code>
</div>
</div>
</section>
<section class="panel software-settings-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.exclusions_title') }}</h2>
<p class="muted">{{ t('software.settings.exclusions_help') }}</p>
</div>
<span class="software-settings-badge">{{ t('software.settings.active') }}</span>
</div>
<div class="software-exclusion-list" id="software-exclusion-list">
{% for rule in software_settings.get('inventory_exclusion_rules', []) %}
<div class="software-exclusion-row">
<label>
<span>{{ t('software.settings.exclude_platform') }}</span>
<select name="inventory_exclusion_platform">
{% for platform in ['all', 'windows', 'linux', 'macos', 'unknown'] %}
<option value="{{ platform }}" {% if rule.get('platform', 'all') == platform %}selected{% endif %}>{{ t('software.platform.' ~ platform, platform) }}</option>
{% endfor %}
</select>
</label>
<label class="software-exclusion-name">
<span>{{ t('software.settings.exclude_name_contains') }}</span>
<input type="text" name="inventory_exclusion_name" maxlength="300" value="{{ rule.get('name_contains', '') }}" placeholder="{{ t('software.settings.exclude_name_placeholder') }}">
</label>
<button type="button" class="button button-secondary software-exclusion-remove" title="{{ t('software.settings.exclude_remove') }}" aria-label="{{ t('software.settings.exclude_remove') }}">{{ t('common.delete') }}</button>
</div>
{% endfor %}
</div>
<template id="software-exclusion-row-template">
<div class="software-exclusion-row">
<label>
<span>{{ t('software.settings.exclude_platform') }}</span>
<select name="inventory_exclusion_platform">
<option value="all">{{ t('software.platform.all', 'All platforms') }}</option>
<option value="windows">{{ t('software.platform.windows', 'Windows') }}</option>
<option value="linux">{{ t('software.platform.linux', 'Linux') }}</option>
<option value="macos">{{ t('software.platform.macos', 'macOS') }}</option>
<option value="unknown">{{ t('software.platform.unknown', 'Unknown') }}</option>
</select>
</label>
<label class="software-exclusion-name">
<span>{{ t('software.settings.exclude_name_contains') }}</span>
<input type="text" name="inventory_exclusion_name" maxlength="300" placeholder="{{ t('software.settings.exclude_name_placeholder') }}">
</label>
<button type="button" class="button button-secondary software-exclusion-remove" title="{{ t('software.settings.exclude_remove') }}" aria-label="{{ t('software.settings.exclude_remove') }}">{{ t('common.delete') }}</button>
</div>
</template>
<div class="software-exclusion-actions">
<button type="button" class="button button-secondary" id="software-exclusion-add">{{ t('software.settings.exclude_add') }}</button>
<small class="muted">{{ t('software.settings.exclude_match_help') }}</small>
</div>
</section>
<div class="form-actions software-settings-actions">
<button type="submit" class="button button-save" data-save-shortcut>{{ t('common.save') }}</button>
</div>
</form>
<section class="panel software-settings-section software-test-section">
<div class="software-settings-section-heading">
<div>
<h2>{{ t('software.settings.test_title') }}</h2>
<p class="muted">{{ t('software.settings.test_help') }}</p>
</div>
<form method="post" action="/settings/software/test-callback" class="inline-form">
<button type="submit" class="button">{{ t('software.settings.run_test') }}</button>
</form>
</div>
<div class="software-debug-toolbar">
<span class="muted">{{ t('software.settings.log_auto_refresh') }}</span>
<div class="software-debug-actions">
<button type="button" class="button button-secondary" id="copy-software-debug-log">{{ t('software.copy_log') }}</button>
<form method="post" action="/settings/software/debug-log/clear" class="inline-form">
<button type="submit" class="button button-secondary">{{ t('software.settings.clear_log') }}</button>
</form>
</div>
</div>
<pre class="sync-log software-debug-log" id="software-debug-log" data-localize-log>{{ debug_log or t('software.settings.no_log') }}</pre>
</section>
<section class="panel software-settings-section software-future-section">
<h2>{{ t('software.settings.future_title') }}</h2>
<p class="muted">{{ t('software.settings.future_help') }}</p>
<div class="software-future-grid">
<span>{{ t('software.settings.future_inventory') }}</span>
<span>{{ t('software.settings.future_retention') }}</span>
<span>{{ t('software.settings.future_packages') }}</span>
<span>{{ t('software.settings.future_security') }}</span>
</div>
</section>
</div>
<script>
async function copyTextRobust(text) {
const value = String(text || '');
if (!value) return false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return true;
}
} catch (_) {}
const area = document.createElement('textarea');
area.value = value;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.left = '-9999px';
area.style.top = '0';
area.style.opacity = '0';
document.body.appendChild(area);
area.focus();
area.select();
area.setSelectionRange(0, area.value.length);
let copied = false;
try {
copied = document.execCommand('copy');
} catch (_) {
copied = false;
}
area.remove();
return copied;
}
(() => {
const baseInput = document.querySelector('input[name="callback_base_url"]');
const preview = document.getElementById('callback-health-preview');
const automaticLabel = {{ t('software.settings.automatic_url')|tojson }};
function updatePreview() {
const base = (baseInput?.value || '').trim().replace(/\/+$/, '');
preview.textContent = base ? base + '/api/software-callback/health' : automaticLabel;
}
baseInput?.addEventListener('input', updatePreview);
const exclusionList = document.getElementById('software-exclusion-list');
const exclusionTemplate = document.getElementById('software-exclusion-row-template');
function bindExclusionRow(row) {
row?.querySelector('.software-exclusion-remove')?.addEventListener('click', () => row.remove());
}
function addExclusionRow() {
if (!exclusionList || !exclusionTemplate) return;
const fragment = exclusionTemplate.content.cloneNode(true);
const row = fragment.querySelector('.software-exclusion-row');
bindExclusionRow(row);
exclusionList.appendChild(fragment);
row?.querySelector('input[name="inventory_exclusion_name"]')?.focus();
}
exclusionList?.querySelectorAll('.software-exclusion-row').forEach(bindExclusionRow);
document.getElementById('software-exclusion-add')?.addEventListener('click', addExclusionRow);
if (exclusionList && exclusionList.children.length === 0) addExclusionRow();
const log = document.getElementById('software-debug-log');
document.getElementById('copy-software-debug-log')?.addEventListener('click', async event => {
const ok = await copyTextRobust(log?.textContent || '');
event.currentTarget.textContent = ok ? {{ t('software.copied')|tojson }} : {{ t('software.copy_failed')|tojson }};
window.setTimeout(() => event.currentTarget.textContent = {{ t('software.copy_log')|tojson }}, 1600);
});
async function refreshLog() {
try {
const response = await fetch('/settings/software/debug-log', {cache: 'no-store'});
if (response.ok) {
const text = await response.text();
log.textContent = text || {{ t('software.settings.no_log')|tojson }}; window.AssetManagerTime?.scan(log);
log.scrollTop = log.scrollHeight;
}
} catch (_) {}
}
log.scrollTop = log.scrollHeight;
window.setInterval(refreshLog, 3000);
})();
</script>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('software.title') }}</h1>
<p class="muted">{{ t('software.central_description') }}</p>
</div>
<a class="button button-secondary" href="/settings/software">{{ t('software.settings.menu') }}</a>
</div>
<div class="software-summary-cards">
<a class="software-summary-card software-summary-link" href="/software/installations">
<span>{{ t('software.total_entries') }}</span>
<strong>{{ software_entry_count }}</strong>
<small>{{ t('software.open_detailed_list') }}</small>
</a>
<a class="software-summary-card software-summary-link" href="/software/summary">
<span>{{ t('software.distinct_software') }}</span>
<strong>{{ software_summary_count }}</strong>
<small>{{ t('software.open_aggregated_list') }}</small>
</a>
<section class="software-summary-card">
<span>{{ t('software.initialized_assets') }}</span>
<strong>{{ initialized_assets }}</strong>
</section>
</div>
<section class="panel software-navigation-panel">
<h2>{{ t('software.lists') }}</h2>
<div class="software-list-links">
<a href="/software/installations" class="software-list-link">
<strong>{{ t('software.all_installations') }}</strong>
<span>{{ t('software.all_installations_help') }}</span>
<span class="button button-secondary">{{ t('software.open') }}</span>
</a>
<a href="/software/summary" class="software-list-link">
<strong>{{ t('software.aggregated_list') }}</strong>
<span>{{ t('software.aggregated_list_help') }}</span>
<span class="button button-secondary">{{ t('software.open') }}</span>
</a>
</div>
</section>
<section class="panel">
<div class="software-log-heading">
<h2>{{ t('software.recent_jobs') }}</h2>
<div class="software-job-heading-actions">
<a class="button button-secondary button-small" href="/software/job-states">▦ {{ t('job_states.open', 'Jobstatus je Asset') }}</a>
<button
type="button"
class="button button-secondary button-small"
id="restart-selected-jobs-button"
data-base-label="↻ {{ t('jobs.restart_selected')|e }}"
disabled
>↻ {{ t('jobs.restart_selected') }}</button>
<form method="get" action="/software" class="inline-form software-job-limit-form">
<label for="software-job-limit">{{ t('software.jobs_count') }}</label>
{% for filter_name, filter_value in job_filters.items() %}
{% if filter_value %}<input type="hidden" name="job_filter_{{ filter_name }}" value="{{ filter_value }}">{% endif %}
{% endfor %}
<select id="software-job-limit" name="job_limit" onchange="this.form.submit()">
{% for option in job_limit_options %}
<option value="{{ option }}" {% if option == job_limit %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
<noscript><button type="submit" class="button button-secondary button-small">{{ t('common.apply') }}</button></noscript>
</form>
</div>
</div>
<p class="muted software-job-filter-help">{{ t('software.jobs_filter_all_help') }}</p>
<form
id="restart-selected-jobs-form"
class="hidden-utility-form"
method="post"
action="/jobs/restart-selected"
data-confirm-template="{{ t('jobs.bulk_restart.confirm')|e }}"
><span id="restart-selected-job-ids"></span></form>
<div class="table-scroll management-table-scroll">
<table
class="data-table"
data-storage-key="software-jobs"
data-server-filters="1"
data-server-filter-url="/software"
data-server-filter-total="{{ jobs_filtered_total if jobs_filters_active else jobs|length }}"
data-restart-label="↻ {{ t('jobs.restart')|e }}"
data-restart-confirm="{{ t('jobs.restart.confirm')|e }}"
>
<thead><tr>
<th class="selection-column" data-no-sort="1" data-no-filter="1"><input type="checkbox" id="select-all-restartable-jobs" aria-label="{{ t('jobs.select_all_restartable')|e }}"></th>
<th data-server-filter-name="job_id" data-server-filter-value="{{ job_filters.job_id|e }}">{{ t('software.job_id') }}</th>
<th data-server-filter-name="created_at" data-server-filter-value="{{ job_filters.created_at|e }}">{{ t('software.time') }}</th>
<th data-server-filter-name="asset" data-server-filter-value="{{ job_filters.asset|e }}">{{ t('common.assets') }}</th>
<th data-server-filter-name="job_type" data-server-filter-value="{{ job_filters.job_type|e }}">{{ t('jobs.type') }}</th>
<th data-server-filter-name="package" data-server-filter-value="{{ job_filters.package|e }}">{{ t('software.package') }}</th>
<th data-server-filter-name="action" data-server-filter-value="{{ job_filters.action|e }}">{{ t('jobs.action') }}</th>
<th data-server-filter-name="priority" data-server-filter-value="{{ job_filters.priority|e }}">{{ t('jobs.priority') }}</th>
<th data-server-filter-name="platform" data-server-filter-value="{{ job_filters.platform|e }}">{{ t('software.platform') }}</th>
<th data-server-filter-name="status" data-server-filter-value="{{ job_filters.status|e }}">{{ t('software.status') }}</th>
<th>{{ t('software.details') }}</th>
<th>{{ t('jobs.actions') }}</th>
</tr></thead>
<tbody>
{% for job in jobs %}
{% set can_bulk_restart = job.status not in ['success', 'sending', 'running'] and job.asset and job.asset.mesh_node_id %}
<tr data-job-id="{{ job.id }}" data-job-status="{{ job.status }}" data-job-has-node="{{ '1' if job.asset and job.asset.mesh_node_id else '0' }}">
<td class="selection-column"><input type="checkbox" class="software-job-restart-select" value="{{ job.id }}" aria-label="{{ t('jobs.select_for_restart', id=job.id)|e }}" {% if not can_bulk_restart %}disabled{% endif %}></td>
<td><a href="/jobs/{{ job.id }}"><code>{{ job.id }}</code></a></td>
<td>{% if job.created_at %}<time class="local-time" datetime="{{ utc_iso(job.created_at) }}" data-utc="{{ utc_iso(job.created_at) }}">{{ job.created_at }}</time>{% else %}{{ '' }}{% endif %}</td>
<td><a href="/assets/{{ job.asset.id }}?inventory_tab=software">{{ job.asset.name }}</a></td>
<td>{{ t(job.display_job_type_key, job.display_job_type_fallback) }}</td>
<td>{{ job.display_package_name }}</td>
<td>{{ t('jobs.action.' ~ job.action, job.action) }}</td>
<td>{{ job.priority }}</td>
<td>{{ job.platform }}</td>
<td><span class="job-status job-status-{{ job.status }}" data-job-status-field>{{ t('software.status.' ~ job.status, job.status) }}</span></td>
<td><a href="/jobs/{{ job.id }}">{{ t('software.open') }}</a></td>
<td data-job-restart-cell>{% if job.status not in ['sending', 'running'] %}<form method="post" action="/jobs/{{ job.id }}/restart" class="inline-form" onsubmit="return confirm({{ t('jobs.restart.confirm')|tojson }});"><button type="submit" class="button button-secondary button-small">↻ {{ t('jobs.restart') }}</button></form>{% else %}<span class="muted"></span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="12">{{ t('software.no_jobs') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<script src="/static/js/software-job-selection.js?v={{ app_version }}"></script>
<script src="/static/js/software-job-status.js?v={{ app_version }}"></script>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('software.all_installations') }}</h1>
<p class="muted">{{ t('software.all_installations_help') }}</p>
</div>
<a class="button button-secondary" href="/software">{{ t('common.back') }}</a>
</div>
<section class="panel" data-software-selection>
<div class="software-section-heading">
<div>
<h2>{{ t('software.detailed_list') }}</h2>
<p class="muted">{{ t('software.table_filter_help') }}</p>
</div>
<span class="software-inventory-count">{{ software_entries|length }}</span>
</div>
<form method="post" action="/software/inventory/delete" data-software-delete-form data-confirm-template="{{ t('software.delete.confirm')|e }}" data-inventory-only-warning="{{ t('software.delete.inventory_only')|e }}" class="software-delete-toolbar">
<input type="hidden" name="redirect_to" value="/software/installations">
<span>{{ t('software.selected_count') }}: <strong data-software-selection-count>0</strong></span>
<button type="submit" class="button button-danger" data-software-delete-button disabled>🗑 {{ t('software.delete.selected') }}</button>
</form>
<div class="table-scroll management-table-scroll software-dedicated-table">
<table class="data-table" id="software-all-installations" data-storage-key="software-all-installations">
<thead><tr>
<th data-no-sort="1"><input type="checkbox" data-software-select-all aria-label="{{ t('software.select_all_visible')|e }}"></th>
<th>{{ t('common.assets') }}</th>
<th>{{ t('common.name') }}</th>
<th>{{ t('about.version') }}</th>
<th>{{ t('software.publisher') }}</th>
<th>{{ t('software.architecture') }}</th>
<th>{{ t('software.platform') }}</th>
<th>{{ t('software.install_date') }}</th>
<th>{{ t('software.last_seen') }}</th>
</tr></thead>
<tbody>
{% for entry in software_entries %}
<tr>
<td><input type="checkbox" value="{{ entry.id }}" data-software-entry-checkbox aria-label="{{ t('software.select_entry', name=entry.name)|e }}"></td>
<td><a href="/assets/{{ entry.asset_id }}?inventory_tab=software">{{ entry.asset_name }}</a></td>
<td>{{ entry.name }}</td>
<td>{{ entry.version or '' }}</td>
<td>{{ entry.publisher or '' }}</td>
<td>{{ entry.architecture or '' }}</td>
<td>{{ t('software.platform.' ~ (entry.platform or 'unknown'), entry.platform or 'unknown') }}</td>
<td data-sort-value="{{ entry.install_date or '' }}">{{ entry.install_date or '' }}</td>
<td data-sort-value="{{ entry.last_seen_at.isoformat() if entry.last_seen_at else '' }}">{% if entry.last_seen_at %}<time class="local-time" datetime="{{ utc_iso(entry.last_seen_at) }}" data-utc="{{ utc_iso(entry.last_seen_at) }}">{{ entry.last_seen_at }}</time>{% else %}{{ '' }}{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="9">{{ t('software.no_global_inventory') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{ t('jobs.title', 'Job {id}').replace('{id}', job.id|string) }}</h1><div class="toolbar-actions">{% if can_restart %}<form method="post" action="/jobs/{{ job.id }}/restart" class="inline-form" onsubmit="return confirm({{ t('jobs.restart.confirm')|tojson }});"><button type="submit">↻ {{ t('jobs.restart') }}</button></form>{% endif %}<a class="button button-secondary" href="/software">{{ t('common.back') }}</a></div></div>
<section class="panel"><dl><dt>{{ t('software.job_id') }}</dt><dd><code>{{ job.id }}</code></dd><dt>{{ t('common.assets') }}</dt><dd><a href="/assets/{{ job.asset.id }}?inventory_tab=software">{{ job.asset.name }}</a></dd><dt>{{ t('jobs.type') }}</dt><dd>{{ t(job.display_job_type_key, job.display_job_type_fallback) }}</dd><dt>{{ t('software.package') }}</dt><dd>{{ job.display_package_name }}</dd><dt>{{ t('jobs.action') }}</dt><dd>{{ t('jobs.action.' ~ job.action, job.action) }}</dd><dt>{{ t('jobs.priority') }}</dt><dd>{{ job.priority }}</dd><dt>{{ t('software.status') }}</dt><dd><span id="job-status" class="job-status job-status-{{ job.status }}">{{ t('software.status.' ~ job.status, job.status) }}</span></dd><dt>{{ t('software.platform') }}</dt><dd>{{ job.platform }}</dd><dt>{{ t('software.created') }}</dt><dd>{% if job.created_at %}<time class="local-time" datetime="{{ utc_iso(job.created_at) }}" data-utc="{{ utc_iso(job.created_at) }}">{{ job.created_at }}</time>{% else %}{{ '' }}{% endif %}</dd><dt>{{ t('jobs.created_by') }}</dt><dd>{{ job.created_by or '—' }}</dd><dt>{{ t('jobs.scheduled_at') }}</dt><dd>{% if job.scheduled_at %}<time class="local-time" datetime="{{ utc_iso(job.scheduled_at) }}" data-utc="{{ utc_iso(job.scheduled_at) }}">{{ job.scheduled_at }}</time>{% else %}{{ '—' }}{% endif %}</dd><dt>{{ t('jobs.attempts') }}</dt><dd>{{ job.attempt_count }} / {{ job.max_attempts }}</dd><dt>{{ t('software.sent') }}</dt><dd>{% if job.sent_at %}<time class="local-time" datetime="{{ utc_iso(job.sent_at) }}" data-utc="{{ utc_iso(job.sent_at) }}">{{ job.sent_at }}</time>{% else %}{{ '' }}{% endif %}</dd><dt>{{ t('software.finished') }}</dt><dd id="job-finished">{% if job.finished_at %}<time class="local-time" datetime="{{ utc_iso(job.finished_at) }}" data-utc="{{ utc_iso(job.finished_at) }}">{{ job.finished_at }}</time>{% else %}{{ '' }}{% endif %}</dd><dt>{{ t('software.exit_code') }}</dt><dd id="job-exit-code">{{ job.exit_code if job.exit_code is not none else '' }}</dd><dt>{{ t('software.message') }}</dt><dd id="job-message">{{ job.message or '' }}</dd></dl></section>
<section class="panel job-data-grid"><div><h2>{{ t('jobs.parameters') }}</h2><pre class="sync-log">{{ job.parameters | tojson(indent=2) }}</pre></div><div><h2>{{ t('jobs.result') }}</h2><pre class="sync-log">{{ job.result_data | tojson(indent=2) }}</pre></div></section>
<section class="panel"><h2>{{ t('jobs.history') }}</h2><div class="table-scroll"><table class="data-table"><thead><tr><th>{{ t('software.time') }}</th><th>{{ t('jobs.attempt') }}</th><th>{{ t('jobs.event') }}</th><th>{{ t('software.status') }}</th><th>{{ t('software.message') }}</th><th>{{ t('jobs.created_by') }}</th></tr></thead><tbody>{% for event in events %}<tr><td><time class="local-time" datetime="{{ utc_iso(event.created_at) }}" data-utc="{{ utc_iso(event.created_at) }}">{{ event.created_at }}</time></td><td>{{ event.attempt }}</td><td>{{ t('jobs.event.' ~ event.event_type, event.event_type) }}</td><td>{{ t('software.status.' ~ event.status, event.status or '—') }}</td><td>{{ event.message or '' }}</td><td>{{ event.created_by or '—' }}</td></tr>{% else %}<tr><td colspan="6">{{ t('jobs.history.empty') }}</td></tr>{% endfor %}</tbody></table></div></section>
<section class="panel"><h2>{{ t('software.diagnostics') }}</h2><p><strong>{{ t('software.local_log') }}:</strong> {% if job.platform == 'windows' %}<code>C:\ProgramData\AssetManager\commands.log</code>{% elif job.platform == 'linux' %}<code>/var/log/assetmanager-commands.log</code>{% elif job.platform == 'macos' %}<code>/Library/Logs/AssetManager-commands.log</code>{% else %}<code></code>{% endif %}</p><p><strong>{{ t('software.callback_health') }}:</strong> <code>/api/software-callback/health</code></p><p class="notice"><strong>{{ t('software.callback_token_warning') }}</strong></p>{% if job.resolved_script %}<label>{{ t('jobs.resolved_script') }}<textarea id="resolved-job-script" class="custom-css-editor" rows="24" readonly>{{ job.resolved_script }}</textarea><small>{{ t('jobs.resolved_script_help') }}</small></label><button type="button" class="button button-secondary" id="copy-resolved-script">{{ t('jobs.copy_resolved_script') }}</button>{% endif %}<details style="margin-top:1rem"><summary>{{ t('jobs.meshctrl_command') }}</summary><label>{{ t('software.manual_command') }}<textarea id="manual-job-command" class="custom-css-editor" readonly>{{ job.command_preview or '' }}</textarea></label><button type="button" class="button button-secondary" id="copy-job-command">{{ t('software.copy_command') }}</button></details></section>
<section class="panel"><div class="software-log-heading"><h2>{{ t('software.client_log') }}</h2><button type="button" class="button button-secondary" data-copy-log="job-client-log">{{ t('software.copy_log') }}</button></div><pre class="sync-log" id="job-client-log" data-localize-log>{{ job.log_text or t('software.no_callback') }}</pre></section><section class="panel"><h2>{{ t('software.meshctrl') }}</h2><div class="software-log-heading"><h3>{{ t('software.stdout') }}</h3><button type="button" class="button button-secondary" data-copy-log="job-meshctrl-stdout">{{ t('software.copy_log') }}</button></div><pre class="sync-log" id="job-meshctrl-stdout" data-localize-log>{{ job.meshctrl_stdout or '' }}</pre><div class="software-log-heading"><h3>{{ t('software.stderr') }}</h3><button type="button" class="button button-secondary" data-copy-log="job-meshctrl-stderr">{{ t('software.copy_log') }}</button></div><pre class="sync-log" id="job-meshctrl-stderr" data-localize-log>{{ job.meshctrl_stderr or '' }}</pre></section>
<script>
async function copyTextRobust(text) {
const value = String(text || '');
if (!value) return false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
return true;
}
} catch (_) {}
const area = document.createElement('textarea');
area.value = value;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.left = '-9999px';
area.style.top = '0';
area.style.opacity = '0';
document.body.appendChild(area);
area.focus();
area.select();
area.setSelectionRange(0, area.value.length);
let copied = false;
try {
copied = document.execCommand('copy');
} catch (_) {
copied = false;
}
area.remove();
return copied;
}
(()=>{document.getElementById('copy-resolved-script')?.addEventListener('click',async event=>{const ok=await copyTextRobust(document.getElementById('resolved-job-script')?.value||'');event.currentTarget.textContent=ok?{{ t('software.copied')|tojson }}:{{ t('software.copy_failed')|tojson }};setTimeout(()=>event.currentTarget.textContent={{ t('jobs.copy_resolved_script')|tojson }},1600);});document.getElementById('copy-job-command')?.addEventListener('click',async event=>{const ok=await copyTextRobust(document.getElementById('manual-job-command')?.value||'');event.currentTarget.textContent=ok?{{ t('software.copied')|tojson }}:{{ t('software.copy_failed')|tojson }};setTimeout(()=>event.currentTarget.textContent={{ t('software.copy_command')|tojson }},1600);});document.querySelectorAll('[data-copy-log]').forEach(button=>button.addEventListener('click',async event=>{const ok=await copyTextRobust(document.getElementById(button.dataset.copyLog)?.textContent||'');event.currentTarget.textContent=ok?{{ t('software.copied')|tojson }}:{{ t('software.copy_failed')|tojson }};setTimeout(()=>event.currentTarget.textContent={{ t('software.copy_log')|tojson }},1600);}));const terminal=new Set(['success','failed','partial','timeout','cancelled']);async function refreshJob(){try{const r=await fetch('/api/software-jobs/{{ job.id }}',{cache:'no-store'});if(!r.ok)return;const d=await r.json();const st=document.getElementById('job-status');if(st){st.textContent=d.status_label;st.className='job-status job-status-'+d.status;}document.getElementById('job-message').textContent=d.message||'';const finished=document.getElementById('job-finished');if(finished){finished.innerHTML=d.finished_at?`<time class="local-time" datetime="${d.finished_at}" data-utc="${d.finished_at}">${d.finished_at}</time>`:'';window.AssetManagerTime?.scan(finished);}document.getElementById('job-exit-code').textContent=d.exit_code??'';document.getElementById('job-client-log').textContent=d.log_text||{{ t('software.no_callback')|tojson }};if(!terminal.has(d.status))setTimeout(refreshJob,3000);}catch(_){setTimeout(refreshJob,5000);}}{% if job.status not in ['success','failed','partial','timeout','cancelled'] %}setTimeout(refreshJob,1500);{% endif %}})();</script>
{% endblock %}
+54
View File
@@ -0,0 +1,54 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('job_states.title', 'Jobstatus je Asset') }}</h1>
<p class="muted">{{ t('job_states.description', 'Aktueller kompakter Jobstatus pro Asset und Job. Die vollständige Historie bleibt in den einzelnen Jobs erhalten.') }}</p>
</div>
<div class="toolbar-actions">
<a class="button button-secondary" href="/software">← {{ t('job_states.back', 'Zur Jobübersicht') }}</a>
</div>
</div>
<section class="panel">
<div class="table-scroll management-table-scroll">
<table class="data-table" data-storage-key="asset-job-states">
<thead>
<tr>
<th>{{ t('common.assets', 'Assets') }}</th>
<th>{{ t('job_states.job', 'Job') }}</th>
<th>{{ t('jobs.type', 'Job-Art') }}</th>
<th>{{ t('software.status', 'Status') }}</th>
<th>{{ t('assets.job_filter.last_execution', 'Letzte Ausführung') }}</th>
<th>{{ t('assets.job_filter.last_success', 'Letzter Erfolg') }}</th>
<th>{{ t('job_states.revision', 'Letzte Revision') }}</th>
<th>{{ t('job_states.success_revision', 'Erfolgsrevision') }}</th>
<th>{{ t('job_states.execution_count', 'Ausführungen') }}</th>
<th>{{ t('job_states.last_job', 'Letzter Job') }}</th>
<th>{{ t('job_states.updated_at', 'Aktualisiert') }}</th>
</tr>
</thead>
<tbody>
{% for row in job_states %}
{% set state = row.state %}
<tr>
<td><a href="/assets/{{ row.asset.id }}?inventory_tab=software">{{ row.asset.name }}</a></td>
<td>{{ row.job_label }}</td>
<td>{{ t(row.job_type_key, row.job_type_fallback) }}</td>
<td><span class="job-status job-status-{{ state.last_status }}">{{ t('software.status.' ~ state.last_status, state.last_status) }}</span></td>
<td>{% if row.last_execution %}<time class="local-time" datetime="{{ utc_iso(row.last_execution) }}" data-utc="{{ utc_iso(row.last_execution) }}">{{ row.last_execution }}</time>{% else %}<span class="muted"></span>{% endif %}</td>
<td>{% if state.last_success_at %}<time class="local-time" datetime="{{ utc_iso(state.last_success_at) }}" data-utc="{{ utc_iso(state.last_success_at) }}">{{ state.last_success_at }}</time>{% else %}<span class="muted"></span>{% endif %}</td>
<td>{% if state.definition_revision is not none %}{{ state.definition_revision }}{% else %}<span class="muted"></span>{% endif %}</td>
<td>{% if state.last_success_revision is not none %}{{ state.last_success_revision }}{% else %}<span class="muted"></span>{% endif %}</td>
<td>{{ state.execution_count }}</td>
<td>{% if state.last_job_id %}<a href="/jobs/{{ state.last_job_id }}"><code>{{ state.last_job_id }}</code></a>{% else %}<span class="muted"></span>{% endif %}</td>
<td>{% if state.updated_at %}<time class="local-time" datetime="{{ utc_iso(state.updated_at) }}" data-utc="{{ utc_iso(state.updated_at) }}">{{ state.updated_at }}</time>{% else %}<span class="muted"></span>{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="11" class="empty-state">{{ t('job_states.empty', 'Noch keine Jobstatusdaten vorhanden.') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends 'base.html' %}
{% block content %}
<div class="toolbar">
<div>
<h1>{{ t('software.aggregated_list') }}</h1>
<p class="muted">{{ t('software.aggregated_list_help') }}</p>
</div>
<a class="button button-secondary" href="/software">{{ t('common.back') }}</a>
</div>
<section class="panel">
<div class="software-section-heading">
<div>
<h2>{{ t('software.summary_table') }}</h2>
<p class="muted">{{ t('software.numeric_filter_help') }}</p>
</div>
<div class="software-summary-controls">
<form method="get" action="/software/summary" class="inline-form software-grouping-form">
<label for="software-summary-grouping">{{ t('software.grouping') }}</label>
<select id="software-summary-grouping" name="grouping" title="{{ t('software.grouping.help')|e }}" onchange="this.form.submit()">
<option value="name_platform" {% if grouping == 'name_platform' %}selected{% endif %}>{{ t('software.grouping.name_platform') }}</option>
<option value="software" {% if grouping == 'software' %}selected{% endif %}>{{ t('software.grouping.software') }}</option>
<option value="version" {% if grouping == 'version' %}selected{% endif %}>{{ t('software.grouping.version') }}</option>
</select>
<noscript><button type="submit" class="button button-secondary button-small">{{ t('common.apply') }}</button></noscript>
</form>
<span class="software-inventory-count">{{ software_summary|length }}</span>
</div>
</div>
<div class="table-scroll management-table-scroll software-dedicated-table">
<table class="data-table" id="software-aggregated" data-storage-key="software-aggregated">
<thead><tr>
<th>{{ t('common.name') }}</th>
<th>{{ t('software.publisher') }}</th>
<th>{{ t('software.platform') }}</th>
<th>{{ t('software.versions') }}</th>
<th data-numeric-filter="1" data-filter-placeholder="{{ t('software.numeric_filter_placeholder') }}">{{ t('software.version_count') }}</th>
<th data-numeric-filter="1" data-filter-placeholder="{{ t('software.numeric_filter_placeholder') }}">{{ t('software.asset_count') }}</th>
<th data-numeric-filter="1" data-filter-placeholder="{{ t('software.numeric_filter_placeholder') }}">{{ t('software.entry_count') }}</th>
<th>{{ t('software.last_seen') }}</th>
</tr></thead>
<tbody>
{% for row in software_summary %}
<tr>
<td>{{ row.name }}</td>
<td>{{ row.publisher }}</td>
<td>{{ t('software.platform.' ~ (row.platform or 'unknown'), row.platform or 'unknown') }}</td>
<td>{{ row.versions }}</td>
<td data-sort-value="{{ row.version_count }}">{{ row.version_count }}</td>
<td data-sort-value="{{ row.asset_count }}">{{ row.asset_count }}</td>
<td data-sort-value="{{ row.entries }}">{{ row.entries }}</td>
<td data-sort-value="{{ row.last_seen_at.isoformat() if row.last_seen_at else '' }}">{% if row.last_seen_at %}<time class="local-time" datetime="{{ utc_iso(row.last_seen_at) }}" data-utc="{{ utc_iso(row.last_seen_at) }}">{{ row.last_seen_at }}</time>{% else %}{{ '' }}{% endif %}</td>
</tr>
{% else %}
<tr><td colspan="8">{{ t('software.no_global_inventory') }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{ t('app.status_values') }}</h1></div><p>{{ t('status.help') }}</p>
<form method="post"><div class="table-scroll management-table-scroll"><table class="data-table"><thead><tr><th>{{ t('common.order') }}</th><th>{{ t('assets.status') }}</th><th>{{ t('common.active') }}</th><th>{{ t('status.on_issue') }}</th><th>{{ t('status.on_return') }}</th><th>{{ t('common.actions') }}</th></tr></thead><tbody>
{% for option in options %}<tr><td><input type="number" name="sort_{{option.id}}" value="{{option.sort_order}}"></td><td><input name="name_{{option.id}}" value="{{option.name}}" required></td><td><input class="checkbox" type="checkbox" name="active_{{option.id}}" {% if option.active %}checked{% endif %}></td><td><input type="radio" name="issue_status_id" value="{{ option.id }}" {% if option.use_for_issue %}checked{% endif %}></td><td><input type="radio" name="return_status_id" value="{{ option.id }}" {% if option.use_for_return %}checked{% endif %}></td><td><button formmethod="post" formaction="/status-options/{{option.id}}/delete" class="icon-button button-danger" onclick="return confirm({{ t('status.delete_confirm')|tojson }});">🗑</button></td></tr>{% endfor %}
<tr><td>{{ t('common.new') }}</td><td><input name="new_name" placeholder="{{ t('status.new_value') }}"></td><td colspan="4"></td></tr></tbody></table></div><div class="form-actions"><button class="button button-save" type="submit">💾 {{ t('common.save') }}</button></div></form>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><h1>{{ t('translations.title') }}</h1><div class="toolbar-actions"><a class="button button-secondary" href="/settings">{{ t('common.back') }}</a><button type="button" class="button" id="add-translation-button"> {{ t('translations.add') }}</button><a class="button" href="/settings/translations/export">📥 {{ t('translations.export_excel') }}</a></div></div>
<div class="panel translation-filter-panel">
<form method="get" class="translation-filter-form">
<label class="checkbox-label translation-filter-checkbox"><input class="checkbox" type="checkbox" name="missing_only" value="1" {% if missing_only %}checked{% endif %}> {{ t('translations.only_missing') }}</label>
<button type="submit">{{ t('common.search') }}</button>
</form>
</div>
<form method="post" action="/settings/translations/delete" id="translation-delete-form" onsubmit="return confirm({{ t('translations.delete_confirm')|tojson }});">
<div class="toolbar-actions translation-selection-actions"><button type="submit" class="button button-danger" id="delete-translations-button" disabled>🗑 {{ t('translations.delete_selected') }}</button></div>
<div class="table-wrap management-table-scroll"><table class="data-table"><thead><tr><th class="selection-column"><input type="checkbox" id="select-all-translations" aria-label="{{ t('translations.select_all') }}"></th><th>{{ t('translations.key') }}</th>{% for language in languages %}<th>{{ language.native_name }} ({{ language.code }})</th>{% endfor %}</tr></thead><tbody>{% for key, values in rows.items() %}<tr><td class="selection-column"><input type="checkbox" class="translation-select" name="translation_keys" value="{{ key }}" {% if key in protected_keys %}disabled title="{{ t('translations.builtin_protected') }}"{% endif %}></td><td><code>{{ key }}</code>{% if key in protected_keys %}<small class="muted block">{{ t('translations.builtin') }}</small>{% endif %}</td>{% for language in languages %}<td><textarea form="translation-save-form" name="tr__{{ language.code }}__{{ key }}" rows="2">{{ values.get(language.code, '') }}</textarea></td>{% endfor %}</tr>{% endfor %}</tbody></table></div>
</form>
<form method="post" action="/settings/translations/save" id="translation-save-form"><button type="submit" class="button button-save">💾 {{ t('common.save') }}</button></form>
<form method="post" action="/settings/translations/import" enctype="multipart/form-data" class="panel translation-import"><h2>{{ t('translations.import_excel') }}</h2><input type="file" name="file" accept=".xlsx" required><label class="checkbox-label"><input type="checkbox" name="ignore_empty" checked> {{ t('translations.ignore_empty') }}</label><button type="submit">📤 {{ t('common.import') }}</button></form>
<dialog id="add-translation-dialog"><form method="post" action="/settings/translations/add"><h2>{{ t('translations.add') }}</h2><label>{{ t('translations.key') }}<input name="translation_key" pattern="[A-Za-z0-9_.-]+" placeholder="example.section.label" required></label>{% for language in languages %}<label>{{ language.native_name }} ({{ language.code }})<textarea name="value_{{ language.code }}" rows="2"></textarea></label>{% endfor %}<div class="form-actions"><button type="button" class="button button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit"> {{ t('translations.add') }}</button></div></form></dialog>
<script>
(() => {
const dialog=document.getElementById('add-translation-dialog'); document.getElementById('add-translation-button')?.addEventListener('click',()=>dialog.showModal());
const boxes=[...document.querySelectorAll('.translation-select:not(:disabled)')], all=document.getElementById('select-all-translations'), button=document.getElementById('delete-translations-button');
const refresh=()=>{const count=boxes.filter(x=>x.checked).length; button.disabled=count===0; all.checked=boxes.length>0&&count===boxes.length; all.indeterminate=count>0&&count<boxes.length;};
boxes.forEach(x=>x.addEventListener('change',refresh)); all?.addEventListener('change',()=>{boxes.forEach(x=>x.checked=all.checked);refresh();}); refresh();
})();
</script>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends 'base.html' %}{% block content %}
<div class="toolbar"><div><h1>{{ t('users.management') }}</h1><p class="muted">{{ t('users.permissions_help') }}</p></div><div class="toolbar-actions"><a class="button button-secondary" href="/settings/ldap-search?show_all=1">{{ t('users.ldap_import') }}</a><a class="button button-secondary" href="/settings/authentication">{{ t('users.login_settings') }}</a></div></div>
<section class="panel"><h2>{{ t('users.create_local') }}</h2><form method="post" action="/users/new" class="compact-form"><div class="form-grid"><label>{{ t('profile.username') }}<input name="username" required></label><label>{{ t('profile.display_name') }}<input name="display_name"></label><label>{{ t('profile.email') }}<input type="email" name="email"></label><label>{{ t('login.password') }}<input type="password" name="password" minlength="8" required></label></div><label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_admin"> {{ t('users.administrator') }}</label><button type="submit"> {{ t('users.new') }}</button></form></section>
<div class="sticky-table management-table-scroll"><table class="data-table"><thead><tr><th>{{ t('users.user') }}</th><th>{{ t('profile.source') }}</th><th>{{ t('profile.last_login') }}</th><th>{{ t('users.permissions_groups') }}</th></tr></thead><tbody>
{% for user in users %}<tr><td><strong>{{ user.username }}</strong>{% if user.is_protected %}<br><span class="software-settings-badge">🔐 {{ t('users.protected_local_admin') }}</span>{% endif %}<br><span class="muted">{{ user.display_name or '' }}{% if user.email %} · {{ user.email }}{% endif %}</span>{% if user.is_protected %}<br><span class="muted">{{ t('users.protected_local_admin_help') }}</span>{% endif %}</td><td>{{ user.auth_source }}</td><td>{% if user.last_login %}<time class="local-time" datetime="{{ utc_iso(user.last_login) }}" data-utc="{{ utc_iso(user.last_login) }}">{{ user.last_login }}</time>{% else %}{{ '' }}{% endif %}</td><td><form method="post" action="/users/{{ user.id }}/update" class="user-permission-form"><div class="form-grid"><label>{{ t('profile.display_name') }}<input name="display_name" value="{{ user.display_name or '' }}"></label><label>{{ t('profile.email') }}<input type="email" name="email" value="{{ user.email or '' }}"></label><label>{{ t('users.profile_department') }}<input name="profile_department" value="{{ user.profile_department or '' }}" placeholder="{{ t('users.profile_department_placeholder') }}"></label><label>{{ t('users.profile_location') }}<input name="profile_location" value="{{ user.profile_location or '' }}" placeholder="{{ t('users.profile_location_placeholder') }}"></label>{% if user.auth_source == 'local' %}<label>{{ t('profile.new_password') }}<input type="password" name="new_password"></label>{% endif %}</div><div class="checkbox-row"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_active" {% if user.is_active %}checked{% endif %} {% if user.is_protected %}disabled{% endif %}> {{ t('common.active') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="is_admin" {% if user.is_admin %}checked{% endif %} {% if user.is_protected %}disabled{% endif %}> {{ t('users.administrator') }}</label></div><fieldset><legend>{{ t('users.asset_visibility') }}</legend><p class="muted">{{ t('users.asset_visibility_help') }}</p><div class="group-checkboxes"><label class="checkbox-label"><input class="checkbox" type="checkbox" name="asset_access_scopes" value="self" {% if 'self' in (user.asset_access_scopes or []) %}checked{% endif %}> {{ t('users.scope_self') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="asset_access_scopes" value="department" {% if 'department' in (user.asset_access_scopes or []) %}checked{% endif %}> {{ t('users.scope_department') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="asset_access_scopes" value="location" {% if 'location' in (user.asset_access_scopes or []) %}checked{% endif %}> {{ t('users.scope_location') }}</label><label class="checkbox-label"><input class="checkbox" type="checkbox" name="asset_access_scopes" value="all" {% if 'all' in (user.asset_access_scopes or []) %}checked{% endif %}> {{ t('users.scope_all') }}</label></div><p class="muted">{{ t('users.scope_or_hint') }}</p></fieldset><fieldset><legend>{{ t('users.visible_mesh_groups') }}</legend><div class="group-checkboxes">{% for group in mesh_groups %}<label class="checkbox-label"><input class="checkbox" type="checkbox" name="mesh_groups" value="{{ group }}" {% if group in (user.allowed_mesh_groups or []) %}checked{% endif %}> {{ group }}</label>{% else %}<span class="muted">{{ t('users.no_mesh_groups') }}</span>{% endfor %}</div></fieldset><button type="submit">💾 {{ t('users.save') }}</button></form>{% if not user.is_protected %}<form method="post" action="/users/{{ user.id }}/delete" class="user-delete-form" onsubmit="return confirm({{ t('users.delete_confirm', username=user.username)|tojson }});"><button type="submit" class="button button-danger">🗑 {{ t('users.delete') }}</button></form>{% endif %}</td></tr>{% else %}<tr><td colspan="4">{{ t('users.none') }}</td></tr>{% endfor %}
</tbody></table></div>
<p class="muted">{{ t('users.relogin_hint') }}</p>
{% endblock %}