Initialer Import des AssetManagers
This commit is contained in:
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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') }}">✎</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 180–300 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"'}[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 %}
|
||||
@@ -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 %}
|
||||
@@ -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}}"
|
||||
@@ -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'
|
||||
@@ -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}}
|
||||
@@ -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%
|
||||
@@ -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}}"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}}")
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user