Files
tesm/srv/tesm/templates/settings.html
T
alientimandClaude Sonnet 5 c9ac0bf69b Lizenz-/Modulsystem Phase 1: TESM-seitiges Gating, Aktivierung, Topbar (v1.2.0)
Baut auf licensing.py (Phase 0) auf und schaltet TESM erstmals auf ein
lizenziertes Modulmodell um. Standard-Funktionen (Dashboard, Clients,
Switche, Zugangsdaten, Benutzer, Gruppen, Auditlog, Live-Log, Verlauf,
Import) bleiben immer frei nutzbar.

- license_active()/module_licensed()-Gate-Funktionen + In-Memory-
  Lizenzcache (_load_license, _write_license_file, _delete_license_file),
  Jinja-Globals fuer Templates.
- Vollstaendige Module (DHCP inkl. Kea-Log, Fileshare, Wartung) werden ohne
  passendes Modul in der Lizenz komplett aus der Navigation ausgeblendet
  (_nav_key_visible) UND serverseitig auf Routenebene abgewiesen
  (_require_module) -- ein direkter URL-Aufruf hilft nicht.
- 'Sichtbar, aber ohne Lizenz inaktiv': LDAP- und NGINX-Einstellungen,
  Papierkorb-Bearbeiten, Export sowie das Pruefintervall-Feld (aus dem
  gemeinsamen Systemeinstellungen-Gate herausgeloest, Hostname/Zeitzone/
  Netzwerk/Logs bleiben frei) zeigen ohne Lizenz einen Hinweis statt der
  Aktion.
- Export bleibt als bewusste Ausnahme auch im lizenzlosen Zustand nutzbar,
  wenn dieser durch eine selbst ausgeloeste Deaktivierung (Systemwechsel)
  entstanden ist (license_export_grace).
- Automatischer PoE-Neustart bei Ausfall wird in poe.sh (nicht in Python)
  gegatet -- einmal pro Schleifendurchlauf gelesen, an check_device()
  durchgereicht; der manuelle Neustart-Button (devices.restart) bleibt
  komplett unangetastet.
- Topbar-Lizenzanzeige (Prioritaet: fehlt > abgelaufen > laeuft in <=30
  Tagen ab > Heartbeat seit >=14 Tagen ausgeblieben (rein informativ) >
  alles ok), eigene CSS-Blink-Varianten fuer Rot/Orange (die vorhandene
  pulse-dot-Animation war fest auf Gruen verdrahtet).
- Neue Settings-Unterseite 'Lizenz': Status, Lizenzdatei-Upload, Online-
  Aktivierung/-Deaktivierung mit automatischem Fallback auf einen manuell
  auszutauschenden Code (ein Protokoll, zwei Transportwege, siehe
  licensing.py), Anbieter-Kontaktkarte.
- 6-stuendiger Heartbeat-Hintergrundthread (_license_heartbeat_loop, exakt
  nach dem Vorbild von _audit_archive_loop).

Isoliert auf dem Testsystem (192.168.82.51) verifiziert: alle fuenf
Topbar-/Sichtbarkeitszustaende (keine/gueltige/bald ablaufende/abgelaufene/
heartbeat-stale Lizenz) per Playwright durchgespielt, kompletter Online-
Fallback-auf-Offline-Code-Austausch fuer Aktivierung UND Deaktivierung
gegen einen simulierten Master erfolgreich durchlaufen (inkl. Export-
Grace-Ausnahme nach Deaktivierung).

Bewusst NICHT nach main gemergt/getaggt/ausgerollt: ohne den in Phase 2
noch zu bauenden Master-Lizenzserver kann POETEST noch keine echte Lizenz
beziehen -- ein Rollout jetzt wuerde dort DHCP/Fileshare/Wartung/LDAP/
NGINX/Papierkorb/Export/automatischen Neustart sofort abschalten. Bleibt
auf dev, bis Phase 2 (Master) + Phase 3 (POETEST-Enterprise-Lizenz) so
weit sind, dass beides zusammen ausgerollt werden kann.

Teil des Lizenz-/Modulsystems, siehe Plan toasty-twirling-hickey.md
(Phase 1 von 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:45:35 +02:00

333 lines
17 KiB
HTML

{% extends "base.html" %}
{% set active_page = "settings_system" %}
{% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Host</h2>
<div class="hint">Name und Zeitzone dieses Hosts.</div>
</div>
</div>
{% set can_edit_host = current_user.has_permission('settings_system.edit') %}
{% if can_edit_host %}
<form method="post">
<div class="field">
<label for="hostname">Hostname</label>
<input type="text" name="hostname" id="hostname" value="{{ current_hostname or '' }}" pattern="[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]?" required>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Hostname setzen
</button>
</form>
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post">
<div class="field">
<label for="timezone">Zeitzone {{ hi.hint_icon("Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.", "Zeitzone") }}</label>
<select name="timezone" id="timezone" required>
{% for tz in timezones %}
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Zeitzone setzen
</button>
</form>
{% else %}
<div class="field">
<label>Hostname</label>
<input type="text" value="{{ current_hostname or '' }}" disabled>
</div>
<div class="field">
<label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled>
</div>
{% endif %}
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
{% if can_edit_host and license_active() %}
<form method="post">
<div class="field">
<label for="interval">Prüfintervall (Minuten) {{ hi.hint_icon("Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.", "Prüfintervall (Minuten)") }}</label>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern & Service neustarten
</button>
</form>
{% else %}
<div class="field">
<label>Prüfintervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled>
<div class="field-hint">
{% if not can_edit_host %}
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
{% else %}
Nur mit gültiger Lizenz änderbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% endif %}
</div>
</div>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Netzwerkeinstellungen</h2>
<div class="hint">IP/DNS/DHCP-Umschaltung dieses Hosts (nicht der DHCP-<em>Server</em> unter „DHCP“).</div>
</div>
</div>
<div class="flex gap-2" style="align-items:center; margin-bottom:12px; flex-wrap:wrap;">
{% if net_backend == 'unknown' %}
<span class="pill unknown">Kein unterstütztes Backend erkannt</span>
{% else %}
<span class="pill online">{{ {'networkmanager': 'NetworkManager', 'dhcpcd': 'dhcpcd', 'netplan': 'netplan'}[net_backend] }}</span>
{% endif %}
</div>
{% for state in active_net_states %}
<div class="flex gap-2" style="align-items:center; margin-bottom:8px; flex-wrap:wrap;">
<span class="mono text-faint" style="font-size:12px;">
{{ state.interface }} — {{ state.ip }}/{{ state.prefix }}{% if state.gateway %}, Gateway {{ state.gateway }}{% endif %}
</span>
<span class="pill {{ 'user' if state.mode == 'static' else ('online' if state.mode == 'dhcp' else 'unknown') }}">
{{ {'static': 'Statisch', 'dhcp': 'DHCP', 'unknown': 'Modus unbekannt'}[state.mode] }}
</span>
{% if state.dns %}
<span class="text-faint" style="font-size:12px;">DNS: <span class="mono">{{ state.dns|join(', ') }}</span></span>
{% endif %}
</div>
{% else %}
<div class="text-faint" style="font-size:12px; margin-bottom:8px;">Kein aktives Interface mit IPv4-Adresse gefunden.</div>
{% endfor %}
<div style="margin-bottom:16px;"></div>
{% if pending_network_token %}
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
<p style="margin:0 0 12px; font-size:13px;">
Neue Netzwerkkonfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung —
bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ net_revert_seconds }}s ohne Bestätigung).
</p>
<form method="post">
<input type="hidden" name="confirm_network" value="{{ pending_network_token }}">
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Verbindung funktioniert — bestätigen
</button>
</form>
</div>
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
<input type="hidden" name="apply_network" value="1">
<div class="field"><label>Interface {{ hi.hint_icon("Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.", "Interface") }}</label>
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
{% for iface in net_interfaces %}
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %}
</select>
</div>
<div class="field"><label>Modus</label>
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
<option value="dhcp" {% if net_state.mode != 'static' %}selected{% endif %}>DHCP (automatisch)</option>
<option value="static" {% if net_state.mode == 'static' %}selected{% endif %}>Statisch</option>
</select>
</div>
<div id="netStaticFields" class="{{ 'hidden' if net_state.mode != 'static' }}">
<div class="field"><label>IP-Adresse</label>
<input type="text" name="net_ip" id="netIpInput" value="{{ net_state.ip if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.50">
</div>
<div class="field"><label>Prefix (CIDR-Bits)</label>
<input type="number" name="net_prefix" id="netPrefixInput" min="1" max="32" value="{{ net_state.prefix if net_state.mode == 'static' else '' }}" placeholder="z.B. 24">
</div>
<div class="field"><label>Gateway</label>
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
</div>
</div>
<div class="field"><label>DNS-Server {{ hi.hint_icon("Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.", "DNS-Server") }}</label>
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Netzwerkkonfiguration anwenden
</button>
<p class="text-faint" style="font-size:11px; margin-top:10px;">
⚠ Kann die Erreichbarkeit dieses Hosts unterbrechen. Ohne Bestätigung wird automatisch nach {{ net_revert_seconds }}s zurückgerollt.
</p>
</form>
{% elif net_backend == 'unknown' %}
<p class="text-faint" style="font-size:12.5px;">
Weder NetworkManager, dhcpcd noch netplan/systemd-networkd aktiv erkannt — Netzwerkänderungen über diese Seite sind deaktiviert.
Bitte die Netzwerkkonfiguration dieses Hosts manuell vornehmen.
</p>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Logs</h2>
<div class="hint">Rotation & Aufbewahrung von Live-, Änderungs-, App- und Kea-DHCP-Log.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<input type="hidden" name="save_log_rotation" value="1">
<div class="field">
<label for="log_rotation_interval">Rotations-Intervall</label>
<select name="log_rotation_interval" id="log_rotation_interval">
<option value="daily" {% if log_rotation_interval == "daily" %}selected{% endif %}>Täglich</option>
<option value="weekly" {% if log_rotation_interval == "weekly" %}selected{% endif %}>Wöchentlich</option>
<option value="monthly" {% if log_rotation_interval == "monthly" %}selected{% endif %}>Monatlich</option>
</select>
</div>
<div class="field">
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen) {{ hi.hint_icon("Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).", "Aufbewahrung (Anzahl Rotationen)") }}</label>
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</form>
{% else %}
<div class="field">
<label>Rotations-Intervall</label>
<input type="text" value="{{ log_rotation_interval }}" disabled>
</div>
<div class="field">
<label>Aufbewahrung (Anzahl Rotationen)</label>
<input type="text" value="{{ log_rotation_keep }}" disabled>
</div>
{% endif %}
<div class="field-hint mono" style="margin-top:10px; font-size:11px; line-height:1.6;">
Live: {{ log_paths.live }}<br>
Änderungen: {{ log_paths.changes }}<br>
App: {{ log_paths.app }}<br>
Kea-DHCP: {{ log_paths.kea }}
</div>
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Papierkorb</h2>
<div class="hint">Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen — Liste unter Geräte → Wartung.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<div class="field">
<label for="trash_retention_days">Aufbewahrungsdauer (Tage) {{ hi.hint_icon("Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).", "Aufbewahrungsdauer (Tage)") }}</label>
<input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
</div>
<button type="submit" name="save_trash_retention" value="1" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Speichern
</button>
</form>
<form method="post" data-confirm="Alle bereits abgelaufenen Papierkorb-Einträge jetzt unwiderruflich löschen?" style="margin-top:10px;">
<input type="hidden" name="purge_trash_now" value="1">
<button type="submit" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);">
Abgelaufene Einträge jetzt bereinigen
</button>
</form>
{% else %}
<div class="field">
<label>Aufbewahrungsdauer (Tage)</label>
<input type="number" value="{{ trash_retention_days }}" disabled>
</div>
{% endif %}
</div>
{% if current_user.is_admin %}
{% macro nav_order_buttons() %}
<div class="nav-order-actions">
<button type="button" class="icon-btn" title="Nach oben" onclick="moveNavItem(this,-1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</button>
<button type="button" class="icon-btn" title="Nach unten" onclick="moveNavItem(this,1)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</button>
</div>
{% endmacro %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Navbar-Reihenfolge</h2>
<div class="hint">Reihenfolge der Sidebar-Menüpunkte inkl. Unterpunkte — gilt für alle Benutzer.</div>
</div>
</div>
<form method="post" action="{{ url_for('save_nav_order') }}" id="navOrderForm">
<ul class="nav-order-list" id="navOrderList">
{% for item in full_nav_items %}
<li data-key="{{ item.key }}">
<div class="nav-order-row">
<span>{{ item.label }}</span>
{{ nav_order_buttons() }}
</div>
<input type="hidden" name="nav_order" value="{{ item.key }}">
{% if item.children %}
<ul class="nav-order-sublist">
{% for child in item.children %}
<li data-key="{{ child.key }}">
<div class="nav-order-row">
<span>{{ child.label }}</span>
{{ nav_order_buttons() }}
</div>
<input type="hidden" name="nav_child_order_{{ item.key }}" value="{{ child.key }}">
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Reihenfolge speichern
</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
function moveNavItem(btn, dir) {
const li = btn.closest("li");
const target = dir === -1 ? li.previousElementSibling : li.nextElementSibling;
if (!target) return;
if (dir === -1) li.parentNode.insertBefore(li, target);
else li.parentNode.insertBefore(target, li);
}
function loadNetworkState(iface) {
fetch("{{ url_for('settings_network_state') }}?interface=" + encodeURIComponent(iface))
.then(r => r.json())
.then(state => {
if (state.error) return;
const isStatic = state.mode === "static";
document.getElementById("netModeSelect").value = isStatic ? "static" : "dhcp";
document.getElementById("netStaticFields").classList.toggle("hidden", !isStatic);
document.getElementById("netIpInput").value = isStatic ? (state.ip || "") : "";
document.getElementById("netPrefixInput").value = isStatic ? (state.prefix || "") : "";
document.getElementById("netGatewayInput").value = isStatic ? (state.gateway || "") : "";
document.getElementById("netDnsInput").value = (state.dns || []).join(", ");
})
.catch(() => {});
}
</script>
{% endblock %}