Fork von TESM (srv/tesm), auf Kunden-/Lizenzverwaltung reduziert statt
Geraete-/PoE-Management. Wiederverwendet unveraendert: Login/Session/
Benutzer- und Gruppenverwaltung, LDAP/AD, NGINX- und Systemeinstellungen,
Live-Log/Verlauf/Auditlog, Im-/Export-Grundgeruest, das komplette
PERMISSIONS/NAV_ITEMS/inject_nav()-Rechtesystem sowie das in Phase 1
gebaute Lizenzsystem selbst sowohl fuer die MASTER-eigene Bootstrap-Lizenz
als auch fuer das exakt gleiche licensing.py (byte-identisch zu TESM --
Signieren/Verifizieren muss zwischen beiden Seiten kompatibel bleiben).
Entfernt: Clients/Switche/Zugangsdaten/DHCP/Fileshare/Wartung/Papierkorb/
manueller PoE-Neustart/SSH-Terminal inkl. aller zugehoerigen Tabellen,
Routen, Permissions, Nav-Eintraege und Abhaengigkeiten (paramiko/Flask-Sock/
simple-websocket/PyNaCl/pyasn1 aus requirements.txt). Benutzer-/Gruppen-
Loeschung von Soft- auf Hard-Delete umgestellt (kein Papierkorb mehr).
Eigener Pfad-/Env-Var-Namespace (TESM_LICENSE_* statt TESM_*, /var/log/
tesm-license statt /var/log/tesm usw.), damit Master und TESM testweise
sogar auf demselben Host nebeneinander laufen koennen, ohne sich Log-/
Config-Pfade streitig zu machen.
Neu -- der eigentliche Lizenzserver:
- license_customers/licenses-Tabellen, Master-Signaturschluessel
(master_signing_key.json, einmalig erzeugt, NIE automatisch rotiert --
jede Kundenlizenz traegt den zum Ausstellungszeitpunkt aktuellen
master_pubkey fest eingebettet).
- Dashboard ('/') als Kunden-/Lizenzuebersicht (Typ, Module, Ablauf,
Status, letzter Heartbeat), eigene Kunden-Verwaltungsseite.
- Lizenz-Ausstellung (Typ/Module/Laufzeit -> signierte Datei via
licensing.issue_license), Detailseite, Download, Widerruf.
- /api/activate, /api/deactivate, /api/heartbeat (unauthentifiziert per
Design -- die Signatur der Anfrage IST der Berechtigungsnachweis) sowie
eine manuelle Offline-Code-Seite, die dieselben drei Verarbeitungs-
funktionen (_process_activate/_process_deactivate/_process_heartbeat)
nutzt wie die Online-API -- ein Protokoll, zwei Transportwege.
- E-Mail-Versand ausgestellter Lizenzen per Microsoft Graph
(Client-Credentials-Flow, reine Standardbibliothek/urllib, keine neue
Abhaengigkeit) inkl. Einrichtungsanleitung und Verbindungstest.
- create_master_license.py: lokales Bootstrap-/Erneuerungs-Skript fuer
die eigene Lizenz des Masters (kein externer Super-Master noetig).
Verifiziert auf dem Testsystem (192.168.82.51): App laeuft parallel zu der
dort laufenden echten TESM-Instanz (Port 5001 vs. 80/5000, eigene
Log-/DB-Pfade, TESM unangetastet). Kompletter ECHTER End-to-End-Rundlauf
per Playwright durchgespielt -- kein simulierter Gegenpart: Kunde anlegen
-> Custom-Lizenz (dhcp+fileshare) ausstellen -> herunterladen -> auf der
echten TESM-Instanz hochladen -> ECHTE Online-Aktivierung (Master
verzeichnet Fingerprint, TESM zeigt 'Aktiviert') -> ECHTE Online-
Deaktivierung (TESM zeigt 'lizenzlos, Export bleibt moeglich', Master
zeigt Status 'Deaktiviert'). Lokale Syntax-/Templatepruefung (py_compile,
pyflakes, jinja2-Parse aller Templates) sauber.
Bewusst NICHT nach main gemergt/getaggt/ausgerollt -- folgt zusammen mit
Phase 3 (Infrastruktur-Umzug + POETEST-Enterprise-Lizenz), siehe Plan
toasty-twirling-hickey.md (Phase 2 von 3).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
373 lines
20 KiB
HTML
373 lines
20 KiB
HTML
{% extends "base.html" %}
|
|
{% set active_page = "settings_system" %}
|
|
{% block page_title %}Systemeinstellungen{% endblock %}
|
|
{% block page_sub %}<div class="topbar-sub">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 %}
|
|
</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.</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- und App-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 }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card card-pad">
|
|
<div class="section-head" style="margin-bottom:16px;">
|
|
<div>
|
|
<h2 style="font-size:16px;">Anbieter & Lizenzserver</h2>
|
|
<div class="hint">Wird in jede ausgestellte Kundenlizenz eingebettet (Kontakt + wo der Client aktivieren/heartbeaten soll).</div>
|
|
</div>
|
|
</div>
|
|
{% if current_user.has_permission('settings_system.edit') %}
|
|
<form method="post" enctype="multipart/form-data">
|
|
<input type="hidden" name="save_vendor_settings" value="1">
|
|
<div class="field">
|
|
<label for="master_endpoint">Lizenzserver-Endpunkt (URL) {{ hi.hint_icon("Die von Kundeninstanzen aus erreichbare Basis-URL dieses Servers, z.B. https://lizenz.example.com. Wird in jede ausgestellte Lizenz eingebettet.", "Lizenzserver-Endpunkt") }}</label>
|
|
<input type="text" name="master_endpoint" id="master_endpoint" value="{{ master_endpoint }}" placeholder="https://lizenz.example.com">
|
|
</div>
|
|
<div class="field">
|
|
<label for="vendor_name">Firma</label>
|
|
<input type="text" name="vendor_name" id="vendor_name" value="{{ vendor.name }}">
|
|
</div>
|
|
<div class="field">
|
|
<label for="vendor_phone">Telefon</label>
|
|
<input type="text" name="vendor_phone" id="vendor_phone" value="{{ vendor.phone }}">
|
|
</div>
|
|
<div class="field">
|
|
<label for="vendor_email">E-Mail</label>
|
|
<input type="email" name="vendor_email" id="vendor_email" value="{{ vendor.email }}">
|
|
</div>
|
|
<div class="field">
|
|
<label for="vendor_address">Anschrift</label>
|
|
<textarea name="vendor_address" id="vendor_address" rows="2">{{ vendor.address }}</textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label for="vendor_logo">Logo (optional, max. 200 KB)</label>
|
|
{% if vendor.logo_base64 %}
|
|
<div style="margin-bottom:8px;"><img src="{{ vendor.logo_base64 }}" alt="Logo" style="max-height:48px; max-width:200px;"></div>
|
|
{% endif %}
|
|
<input type="file" name="vendor_logo" id="vendor_logo" accept=".png,.jpg,.jpeg,.svg">
|
|
</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 %}
|
|
<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;">E-Mail-Versand (Microsoft Graph)</h2>
|
|
<div class="hint">Für den direkten Versand ausgestellter Lizenzen an Kunden per E-Mail (optional -- Download funktioniert immer, auch ohne dies einzurichten).</div>
|
|
</div>
|
|
</div>
|
|
<details style="margin-bottom:14px;">
|
|
<summary style="cursor:pointer; font-size:12.5px; color:var(--text-dim);">Einrichtungsanleitung (Azure AD App-Registrierung)</summary>
|
|
<ol style="font-size:12px; color:var(--text-faint); margin:10px 0 0; padding-left:18px; line-height:1.7;">
|
|
<li>Im <a href="https://portal.azure.com" target="_blank" rel="noopener">Azure-Portal</a> unter „Azure Active Directory → App-Registrierungen“ eine neue App anlegen.</li>
|
|
<li>Unter „API-Berechtigungen“ die Anwendungsberechtigung (nicht delegiert!) <code>Mail.Send</code> für Microsoft Graph hinzufügen und per „Administratorzustimmung erteilen“ bestätigen.</li>
|
|
<li>Unter „Zertifikate & Geheimnisse“ einen neuen Client-Secret-Wert erzeugen und sofort kopieren (wird nur einmal angezeigt).</li>
|
|
<li>Tenant-ID und Client-ID stehen auf der Übersichtsseite der App-Registrierung.</li>
|
|
<li>Als Absender-Postfach eine echte, lizenzierte Mailbox im selben Tenant angeben (z.B. lizenz@firma.de).</li>
|
|
<li>Optional, aber empfohlen: per Exchange-Online-„Application Access Policy“ die App-Berechtigung auf genau dieses Postfach einschränken, statt tenant-weit jede Mailbox versenden zu lassen.</li>
|
|
<li>Da dies ein reiner App-zu-App-Login ohne Benutzeranmeldung ist (Client-Credentials-Flow), spielt eine ggf. für Benutzer aktivierte MFA-Pflicht hier keine Rolle.</li>
|
|
</ol>
|
|
</details>
|
|
{% if current_user.has_permission('settings_system.edit') %}
|
|
<form method="post">
|
|
<input type="hidden" name="save_graph_settings" value="1">
|
|
<div class="field">
|
|
<label for="graph_tenant_id">Tenant-ID</label>
|
|
<input type="text" name="graph_tenant_id" id="graph_tenant_id" value="{{ graph.tenant_id }}">
|
|
</div>
|
|
<div class="field">
|
|
<label for="graph_client_id">Client-ID</label>
|
|
<input type="text" name="graph_client_id" id="graph_client_id" value="{{ graph.client_id }}">
|
|
</div>
|
|
<div class="field">
|
|
<label for="graph_client_secret">Client-Secret</label>
|
|
<input type="password" name="graph_client_secret" id="graph_client_secret" value="{{ graph.client_secret }}" autocomplete="off">
|
|
</div>
|
|
<div class="field">
|
|
<label for="graph_sender_mailbox">Absender-Postfach</label>
|
|
<input type="email" name="graph_sender_mailbox" id="graph_sender_mailbox" value="{{ graph.sender_mailbox }}" placeholder="lizenz@firma.de">
|
|
</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>
|
|
<form method="post" action="{{ url_for('settings_email_test') }}" style="margin-top:10px;">
|
|
<button type="submit" class="btn btn-secondary btn-block">Verbindung testen</button>
|
|
</form>
|
|
{% else %}
|
|
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</p>
|
|
{% 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 %}
|