Rebrand to TESM: infra rename, LDAP/AD login, selective import/export, Papierkorb
- Vollständiges Rebranding "PoE Manager" -> TESM (TimEShepManager): neue Logos/Favicon (theme-aware), Sidebar/Login-Branding, Copyright-Zeile. - Infrastruktur-Umbenennung: srv/poe_manager -> srv/tesm, alle POE_*-Env-Vars -> TESM_* (POE_SCRIPT bewusst unverändert, echtes PoE-Skript), systemd Units poe_web/rpi-check* -> tesm/tesm-check*, nginx-Site, netplan/logrotate Configs, Gitea-Repo alientim/Aruba-PoE-Modern -> alientim/tesm. - LDAP/Active-Directory Same-Sign-On: Search+Bind, AD-Gruppen->App-Gruppen- Zuordnung (additiv), Konto-Sperren, AD-Vorab-Suche/-Anlage, eigene Einstellungsseite. - Selektives Import/Export (8 Kategorien, Zwei-Schritt-Vorschau) mit eigenem R/E/X-Rechtemodell (X = Export, getrennt von E = Import). - Papierkorb: Soft-Delete statt Hard-Delete für Geräte/Switche/Zugangsdaten/ Benutzer/Gruppen (AD-Nutzer ausgenommen), eigene Seite unterhalb Wartung, konfigurierbare Aufbewahrungsfrist. - rpi_ip -> ip Spalten-/Code-Umbenennung (Geräte sind längst nicht mehr auf Raspberry Pis beschränkt). - README auf aktuellen Stand gebracht.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
<!-- Live-Update-fähiges Fragment: wird von index.html initial eingebunden UND
|
||||
unverändert von /dashboard/tiles (AJAX) nachgeladen, damit das Dashboard
|
||||
ohne vollen Seiten-Reload aktuell bleibt (siehe dashboard-tiles.js im
|
||||
scripts-Block von index.html). -->
|
||||
<div id="dashboard-tiles" data-last-run-ms="{{ last_run_epoch_ms or '' }}">
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-card" data-status-filter="online">
|
||||
<div class="stat-label">Online</div>
|
||||
<div class="stat-value online">{{ stats.online }}</div>
|
||||
</div>
|
||||
<div class="stat-card" data-status-filter="offline">
|
||||
<div class="stat-label">Offline</div>
|
||||
<div class="stat-value offline">{{ stats.offline }}</div>
|
||||
</div>
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="stat-card" data-status-filter="disabled">
|
||||
<div class="stat-label">Deaktiviert</div>
|
||||
<div class="stat-value disabled">{{ stats.disabled }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="stat-card" data-status-filter="">
|
||||
<div class="stat-label">Gesamt</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% macro device_tile(d) %}
|
||||
{% set st = status.get(d['mac'], 'unbekannt') %}
|
||||
<div class="device-card {% if d['is_active'] == 0 %}is-disabled{% endif %} {% if not current_user.is_authenticated %}is-readonly{% endif %}"
|
||||
data-status="{{ 'disabled' if d['is_active'] == 0 else st }}"
|
||||
data-mac="{{ d['mac'] }}"
|
||||
data-name="{{ d['name'] }}"
|
||||
{% if current_user.is_authenticated %}data-ip="{{ d['ip'] }}"{% endif %}
|
||||
data-switch="{{ d['switch_hostname'] or '-' }}"
|
||||
data-port="{{ d['port'] or '-' }}"
|
||||
data-active="{{ d['is_active'] }}"
|
||||
data-checked="{{ last_checked.get(d['mac']) or '-' }}"
|
||||
{% if last_seen.get(d['mac']) %}title="{{ last_seen[d['mac']] }}"{% endif %}>
|
||||
<div class="dc-top">
|
||||
<div class="dc-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2.5"/><path d="M8 2v3M16 2v3M8 19v3M16 19v3M2 8h3M2 16h3M19 8h3M19 16h3"/></svg>
|
||||
</div>
|
||||
{% if d['is_active'] == 0 %}
|
||||
<span class="pill disabled">Deaktiviert</span>
|
||||
{% else %}
|
||||
<span class="pill {{ st }}">{{ {'online':'Online','offline':'Offline','unbekannt':'Unbekannt'}[st] }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="dc-name">{{ d['name'] }}</div>
|
||||
{% if current_user.is_authenticated %}<div class="dc-ip">{{ d['ip'] }}</div>{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% if device_count %}
|
||||
|
||||
{% macro section_chevron() %}<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>{% endmacro %}
|
||||
|
||||
{% if offline_devices %}
|
||||
<div class="dash-section" data-status="offline">
|
||||
<div class="dash-section-title">{{ section_chevron() }}Offline <span class="pill offline">{{ offline_devices|length }}</span></div>
|
||||
<div class="device-grid">
|
||||
{% for d in offline_devices %}{{ device_tile(d) }}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if online_devices %}
|
||||
<div class="dash-section" data-status="online">
|
||||
<div class="dash-section-title">{{ section_chevron() }}Online <span class="pill online">{{ online_devices|length }}</span></div>
|
||||
<div class="device-grid">
|
||||
{% for d in online_devices %}{{ device_tile(d) }}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if disabled_devices %}
|
||||
<div class="dash-section" data-status="disabled">
|
||||
<div class="dash-section-title">{{ section_chevron() }}Deaktiviert <span class="pill disabled">{{ disabled_devices|length }}</span></div>
|
||||
<div class="device-grid">
|
||||
{% for d in disabled_devices %}{{ device_tile(d) }}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p id="noSearchResults" class="text-faint hidden" style="text-align:center; padding:30px 0;">Kein Gerät gefunden.</p>
|
||||
|
||||
{% else %}
|
||||
<div class="card card-pad" style="text-align:center; color:var(--text-faint);">
|
||||
{% if current_user.is_authenticated %}
|
||||
Noch keine Geräte angelegt. Füge welche unter <a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Devices</a> hinzu.
|
||||
{% else %}
|
||||
Aktuell keine Geräte verfügbar.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}Mein Konto{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ current_user.username }}</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="settings-grid">
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Profil</h2>
|
||||
<div class="hint">Name und Profilbild ändern sich in der Sidebar und im Änderungslog.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; align-items:center; gap:14px; margin-bottom:20px;">
|
||||
{% if current_user.avatar_url %}
|
||||
<img src="{{ current_user.avatar_url }}" alt="" style="width:56px; height:56px; border-radius:50%; object-fit:cover;">
|
||||
{% else %}
|
||||
<div class="user-avatar" style="width:56px; height:56px; font-size:18px;">{{ current_user.username[:2]|upper }}</div>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('profile') }}" enctype="multipart/form-data" id="avatarForm" style="flex:1;">
|
||||
<input type="file" name="avatar" id="avatarInput" accept="image/png,image/jpeg,image/gif,image/webp" style="display:none;" onchange="document.getElementById('avatarForm').requestSubmit();">
|
||||
<button type="button" class="btn btn-secondary" style="width:100%;" onclick="document.getElementById('avatarInput').click();">Profilbild ändern</button>
|
||||
<input type="hidden" name="upload_avatar" value="1">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ url_for('profile') }}" id="profileForm">
|
||||
<div class="field"><label>Vorname</label><input type="text" name="first_name" value="{{ current_user.first_name or '' }}"></div>
|
||||
<div class="field"><label>Name</label><input type="text" name="last_name" value="{{ current_user.last_name or '' }}"></div>
|
||||
<div class="field"><label>Username</label><input type="text" value="{{ current_user.username }}" disabled></div>
|
||||
<button type="submit" name="update_profile" value="1" class="btn btn-primary btn-block">Speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Passwort ändern</h2>
|
||||
<div class="hint">
|
||||
{% if current_user.is_ldap_user %}Wird über Active Directory verwaltet.{% else %}Erfordert Eingabe des aktuellen Passworts.{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.is_ldap_user %}
|
||||
<p class="text-faint" style="font-size:12.5px;">
|
||||
Dieses Konto meldet sich über Active Directory/LDAP an — das Passwort wird dort verwaltet
|
||||
und kann in dieser App nicht geändert werden. Bitte das Domänen-Passwort wie gewohnt ändern.
|
||||
</p>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('profile') }}" id="passwordForm">
|
||||
<div class="field"><label>Aktuelles Passwort</label><input type="password" name="current_password" autocomplete="current-password"></div>
|
||||
<div class="field"><label>Neues Passwort</label><input type="password" name="new_password" autocomplete="new-password"></div>
|
||||
<div class="field"><label>Neues Passwort bestätigen</label><input type="password" name="confirm_password" autocomplete="new-password"></div>
|
||||
<button type="submit" name="change_password" value="1" class="btn btn-secondary btn-block">Passwort ändern</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Änderungen{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% set action_labels = {
|
||||
"settings.update": "Einstellung geändert",
|
||||
"device.create": "Gerät angelegt",
|
||||
"device.edit": "Gerät bearbeitet",
|
||||
"device.delete": "Gerät gelöscht",
|
||||
"device.activate": "Gerät aktiviert",
|
||||
"device.deactivate": "Gerät deaktiviert",
|
||||
"switch.create": "Switch angelegt",
|
||||
"switch.edit": "Switch bearbeitet",
|
||||
"switch.delete": "Switch gelöscht",
|
||||
"credential.create": "Zugangsdaten angelegt",
|
||||
"credential.edit": "Zugangsdaten bearbeitet",
|
||||
"credential.delete": "Zugangsdaten gelöscht",
|
||||
"user.create": "Benutzer angelegt",
|
||||
"user.edit": "Benutzer bearbeitet",
|
||||
"user.delete": "Benutzer gelöscht",
|
||||
"user.assign_group": "Gruppe zugewiesen",
|
||||
"group.create": "Gruppe angelegt",
|
||||
"group.edit": "Gruppe bearbeitet",
|
||||
"group.delete": "Gruppe gelöscht",
|
||||
"group.assign_admins": "Admin-Zuweisung geändert",
|
||||
"profile.update": "Profil aktualisiert",
|
||||
"profile.password": "Passwort geändert",
|
||||
"check.run_now": "Prüfung manuell gestartet",
|
||||
} %}
|
||||
{% set action_icons = {
|
||||
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
|
||||
"create": '<path d="M12 5v14M5 12h14"/>',
|
||||
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
|
||||
"activate": '<path d="M20 6L9 17l-5-5"/>',
|
||||
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
|
||||
} %}
|
||||
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th style="width:1%;"></th><th>Zeitpunkt</th><th>Benutzer</th><th>Aktion</th><th>Ziel</th><th>Details</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
{% set kind = e['action'].split('.')[-1] %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if avatars.get(e['username']) %}
|
||||
<img class="avatar-sm" src="{{ url_for('static', filename='uploads/avatars/' + avatars[e['username']]) }}" alt="">
|
||||
{% else %}
|
||||
<span class="avatar-sm avatar-placeholder">{{ e['username'][:1]|upper }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
|
||||
<td class="cell-name">{{ e['username'] }}</td>
|
||||
<td>
|
||||
<span class="pill action-pill">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
|
||||
{{ action_labels.get(e['action'], e['action']) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ e['target'] or '—' }}</td>
|
||||
<td class="text-dim">{{ e['details'] or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="6">Noch keine Änderungen protokolliert.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ title or "TESM" }}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/style.css') }}">
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% set icons = {
|
||||
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
||||
"cpu": '<rect x="6" y="6" width="12" height="12" rx="1.5"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M1 9h3M1 15h3M20 9h3M20 15h3"/>',
|
||||
"share": '<circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/>',
|
||||
"users": '<circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/>',
|
||||
"groups": '<rect x="3" y="4" width="8" height="7" rx="1.5"/><rect x="13" y="4" width="8" height="7" rx="1.5"/><rect x="3" y="13" width="8" height="7" rx="1.5"/><rect x="13" y="13" width="8" height="7" rx="1.5"/>',
|
||||
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
|
||||
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
|
||||
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
|
||||
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
|
||||
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
|
||||
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
|
||||
"transfer": '<path d="M17 3l4 4-4 4"/><path d="M3 7h18"/><path d="M7 21l-4-4 4-4"/><path d="M21 17H3"/>',
|
||||
"network": '<rect x="9" y="2" width="6" height="6" rx="1.2"/><rect x="2" y="16" width="6" height="6" rx="1.2"/><rect x="16" y="16" width="6" height="6" rx="1.2"/><path d="M12 8v4M12 12H5v4M12 12h7v4"/>',
|
||||
"wrench": '<path d="M14.7 6.3a4 4 0 11-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 015.4-5.4z"/>',
|
||||
"trash": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6h16z"/><path d="M10 11v6M14 11v6"/>',
|
||||
} %}
|
||||
|
||||
<div class="app-shell">
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="sidebar-backdrop" data-sidebar-toggle></div>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<img id="sidebar-logo" src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM" style="width:100%; height:auto; display:block;">
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
{% for item in nav_items_ordered %}
|
||||
{% if item.children %}
|
||||
{% set child_active = item.children|selectattr('endpoint', 'equalto', request.endpoint)|list %}
|
||||
<div class="nav-group {% if child_active %}expanded active-group{% endif %}" data-nav-group data-nav-group-key="{{ item.key }}">
|
||||
<button type="button" class="nav-group-toggle" data-nav-group-toggle>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
|
||||
{{ item.label }}
|
||||
<svg class="nav-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</button>
|
||||
<div class="nav-group-children">
|
||||
{% for child in item.children %}
|
||||
<a href="{{ url_for(child.endpoint) }}" class="nav-item {% if request.endpoint == child.endpoint %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[child.icon]|safe }}</svg>
|
||||
{{ child.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{{ url_for(item.endpoint) }}" class="nav-item {% if active_page == item.key %}active{% endif %}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons[item.icon]|safe }}</svg>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-chip">
|
||||
{% if current_user.avatar_url %}
|
||||
<img class="user-avatar" src="{{ current_user.avatar_url }}" alt="" style="object-fit:cover;">
|
||||
{% else %}
|
||||
<div class="user-avatar">{{ current_user.username[:2]|upper }}</div>
|
||||
{% endif %}
|
||||
<div class="user-meta">
|
||||
<div class="u-name">{{ current_user.display_name }}</div>
|
||||
<div class="u-role">{{ "Administrator" if current_user.is_admin else current_user.group_names or "Benutzer" }}</div>
|
||||
</div>
|
||||
<a href="{{ url_for('account') }}" class="icon-btn" title="Einstellungen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ icons['gear']|safe }}</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<button type="button" class="icon-btn" data-theme-toggle title="Theme wechseln">
|
||||
<span data-theme-icon></span>
|
||||
</button>
|
||||
<a href="{{ url_for('logout') }}" class="icon-btn" title="Abmelden" style="flex:1; display:flex;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin:auto;">{{ icons['logout']|safe }}</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-copyright" style="padding:10px 20px 4px; font-size:10.5px; color:var(--text-faint); text-align:center;">
|
||||
© {{ current_year }} TESM — TimEShepManager
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
{% endif %}
|
||||
|
||||
<div class="main {% if not current_user.is_authenticated %}no-sidebar{% endif %}">
|
||||
<div class="topbar">
|
||||
<div class="topbar-left">
|
||||
{% if current_user.is_authenticated %}
|
||||
<button type="button" class="hamburger" data-sidebar-toggle aria-label="Menü">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="topbar-title">{% block page_title %}Dashboard{% endblock %}</div>
|
||||
{% block page_sub %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-logo">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<span class="timer-pill" id="global-timer-pill"
|
||||
data-last-run-ms="{{ global_last_run_epoch_ms or '' }}"
|
||||
data-interval-ms="{{ (global_check_interval * 60000) if global_check_interval else '' }}">
|
||||
<span class="dot"></span>Nächste Prüfung in <span id="global-timer">--</span>s
|
||||
{% if current_user.is_admin %}
|
||||
<button type="button" class="timer-pill-refresh" id="run-check-now" title="Jetzt prüfen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if dhcp_topbar_active is not none %}
|
||||
<span class="timer-pill" title="DHCP-Dienst: {{ 'aktiv' if dhcp_topbar_active else 'inaktiv' }}">
|
||||
<span class="dot" style="animation:none; background:{{ 'var(--success)' if dhcp_topbar_active else 'var(--danger)' }}; box-shadow:none;"></span>DHCP
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if not current_user.is_authenticated %}
|
||||
<a href="{{ url_for('login') }}" 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="M15 3h4a2 2 0 012 2v14a2 2 0 01-2 2h-4"/><path d="M10 17l5-5-5-5"/><path d="M15 12H3"/></svg>
|
||||
Login
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
<script type="application/json" id="flashed-data">{{ messages|tojson }}</script>
|
||||
{% endwith %}
|
||||
|
||||
<script src="{{ asset_url('js/app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "credentials" %}
|
||||
{% set can_create = current_user.has_permission('credentials.create') %}
|
||||
{% set can_edit = current_user.has_permission('credentials.edit') %}
|
||||
{% set can_delete = current_user.has_permission('credentials.edit') %}
|
||||
{% set category_labels = dict(categories) %}
|
||||
{% block page_title %}Zugangsdaten{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Zugangsdaten</h2>
|
||||
<div class="hint">Können mehreren Switchen zugleich zugeordnet werden.</div>
|
||||
</div>
|
||||
{% if can_create %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addCredentialModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neue Zugangsdaten
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="credentialSearch" placeholder="Zugangsdaten durchsuchen…" oninput="filterTable('credentialSearch','credentialsTable')">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="credentialsTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="name">Name</th>
|
||||
<th data-sort-key="username">Username</th>
|
||||
<th data-sort-key="category">Kategorie</th>
|
||||
<th data-sort-key="usage">Verwendet von</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for c in credentials %}
|
||||
{% set usage_total = c['switch_count'] + c['device_count'] %}
|
||||
<tr data-sort-name="{{ c['name']|lower }}" data-sort-username="{{ c['username']|lower }}" data-sort-category="{{ c['category'] }}" data-sort-usage="{{ usage_total }}">
|
||||
<td class="cell-name">{{ c['name'] }}</td>
|
||||
<td class="mono">{{ c['username'] }}</td>
|
||||
<td>{{ category_labels.get(c['category'], c['category']) }}</td>
|
||||
<td class="text-dim">
|
||||
{% if usage_total == 0 %}—{% else %}
|
||||
{% if c['switch_count'] %}{{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }}{% endif %}
|
||||
{% if c['switch_count'] and c['device_count'] %}, {% endif %}
|
||||
{% if c['device_count'] %}{{ c['device_count'] }} Gerät{{ 'e' if c['device_count'] != 1 else '' }}{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
<button class="icon-btn" title="Bearbeiten"
|
||||
onclick="openEditCredentialModal({{ c['id'] }}, '{{ c['name'] }}', '{{ c['username'] }}', '{{ c['category'] }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<form method="post" data-confirm="Zugangsdaten „{{ c['name'] }}“ wirklich löschen?">
|
||||
<input type="hidden" name="delete_credential" value="{{ c['id'] }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="5">Noch keine Zugangsdaten vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_create %}
|
||||
<!-- Modal: Neue Zugangsdaten -->
|
||||
<div class="modal-overlay" id="addCredentialModal">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
<form method="post" onsubmit="return validateCredentialForm(this, 'add');">
|
||||
<div class="modal-header">
|
||||
<h3>Neue Zugangsdaten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_credential" value="1">
|
||||
<div class="field"><label>Name</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Standard-Switch-Login">
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="username" required placeholder="z.B. admin">
|
||||
</div>
|
||||
<div class="field"><label>Kategorie</label>
|
||||
<select name="category">
|
||||
{% for key, label in categories %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="hint">Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.</div>
|
||||
</div>
|
||||
<div class="field"><label>Passwort</label>
|
||||
<input type="password" id="password_add" name="password" required>
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_add" name="password_confirm" required>
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<!-- Modal: Zugangsdaten bearbeiten -->
|
||||
<div class="modal-overlay" id="editCredentialModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post" onsubmit="return validateCredentialForm(this, 'edit');">
|
||||
<input type="hidden" name="edit_credential" value="1">
|
||||
<input type="hidden" name="credential_id" id="edit_cred_id">
|
||||
<div class="modal-header">
|
||||
<h3>Zugangsdaten bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Name</label>
|
||||
<input type="text" name="name" id="edit_cred_name" required>
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="username" id="edit_cred_username" required>
|
||||
</div>
|
||||
<div class="field"><label>Kategorie</label>
|
||||
<select name="category" id="edit_cred_category">
|
||||
{% for key, label in categories %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Neues Passwort</label>
|
||||
<input type="password" id="password_edit" name="password" placeholder="Nur bei Änderung ausfüllen">
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_edit" name="password_confirm" placeholder="Bestätigen">
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function openEditCredentialModal(id, name, username, category) {
|
||||
document.getElementById("edit_cred_id").value = id;
|
||||
document.getElementById("edit_cred_name").value = name;
|
||||
document.getElementById("edit_cred_username").value = username;
|
||||
document.getElementById("edit_cred_category").value = category;
|
||||
document.getElementById("password_edit").value = "";
|
||||
document.getElementById("password_confirm_edit").value = "";
|
||||
PoeUI.openModal("editCredentialModal");
|
||||
}
|
||||
|
||||
function filterTable(inputId, tableId) {
|
||||
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function validateCredentialForm(form, id) {
|
||||
const pass = document.getElementById("password_" + id);
|
||||
const confirm = document.getElementById("password_confirm_" + id);
|
||||
if (!pass || !confirm) return true;
|
||||
if (pass.value || confirm.value || id === "add") {
|
||||
if (pass.value !== confirm.value) {
|
||||
confirm.classList.add("is-invalid");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
confirm.classList.remove("is-invalid");
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,535 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/xterm.css') }}">
|
||||
{% endblock %}
|
||||
{% set active_page = "devices" %}
|
||||
{% set can_toggle = current_user.has_permission('devices.edit') %}
|
||||
{% set can_create = current_user.has_permission('devices.create') %}
|
||||
{% set can_edit = current_user.has_permission('devices.edit') %}
|
||||
{% set can_delete = current_user.has_permission('devices.edit') %}
|
||||
{% set show_actions_col = can_edit or can_delete %}
|
||||
{% set col_count = 6 + (1 if can_toggle else 0) + (1 if show_actions_col else 0) %}
|
||||
{% block page_title %}Clients{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Geräte</h2>
|
||||
<div class="hint">PoE-Geräte mit IP, MAC und zugeordnetem Switch-Port.</div>
|
||||
</div>
|
||||
{% if can_create %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="deviceModal" onclick="resetDeviceForm()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neues Gerät
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="deviceSearch" placeholder="Geräte durchsuchen…" oninput="filterTable('deviceSearch','devicesTable')">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="devicesTable" data-sortable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort-key="hostname">Hostname</th>
|
||||
<th data-sort-key="ip">IP-Adresse</th>
|
||||
<th data-sort-key="mac">MAC-Adresse</th>
|
||||
<th data-sort-key="switch">Switch</th>
|
||||
<th data-sort-key="port">Switchport</th>
|
||||
<th data-sort-key="credential">Zugangsdaten</th>
|
||||
{% if can_toggle %}<th>Status</th>{% endif %}
|
||||
{% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
<tr data-sort-hostname="{{ d['name']|lower }}" data-sort-ip="{{ d['ip']|lower }}" data-sort-mac="{{ d['mac']|lower }}" data-sort-switch="{{ (d['switch_hostname'] or '')|lower }}" data-sort-port="{{ (d['port'] or '')|lower }}" data-sort-credential="{{ (d['credential_name'] or '')|lower }}">
|
||||
<td class="cell-name">{{ d['name'] }}</td>
|
||||
<td class="mono">{{ d['ip'] }}</td>
|
||||
<td class="mono">{{ d['mac'] }}</td>
|
||||
<td>{{ d['switch_hostname'] or '—' }}</td>
|
||||
<td>{{ d['port'] or '—' }}</td>
|
||||
<td>
|
||||
{% if d['credential_name'] %}
|
||||
{{ d['credential_name'] }} <span class="text-faint mono" style="font-size:11.5px;">({{ d['credential_username'] }})</span>
|
||||
{% else %}
|
||||
<span class="text-faint">— keine —</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% if can_toggle %}
|
||||
<td>
|
||||
<label class="switch-check">
|
||||
<input type="checkbox" {% if d['is_active'] %}checked{% endif %} onchange="toggleDevice('{{ d['mac'] }}', this)">
|
||||
<span class="track"></span>
|
||||
</label>
|
||||
</td>
|
||||
{% endif %}
|
||||
{% if show_actions_col %}
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
<button class="icon-btn" title="Bearbeiten"
|
||||
onclick="openEditDeviceModal('{{ d['mac'] }}','{{ d['name'] }}','{{ d['ip'] }}','{{ d['port'] or '' }}','{{ d['ssh_port'] or '' }}','{{ d['credential_id'] or '' }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Switch ändern"
|
||||
onclick="openSwitchModal('{{ d['mac'] }}','{{ d['switch_hostname'] or '' }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<form method="post" data-confirm="Willst du „{{ d['name'] }}“ wirklich löschen?">
|
||||
<input type="hidden" name="delete_device" value="{{ d['mac'] }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="{{ col_count }}">Noch keine Geräte vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_create %}
|
||||
<!-- Modal: Neues Gerät -->
|
||||
<div class="modal-overlay" id="deviceModal">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
<form method="post" onsubmit="return validateDeviceForm(this);">
|
||||
<div class="modal-header">
|
||||
<h3>Neues Gerät hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_device" value="1">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Sensor01">
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>MAC-Adresse</label>
|
||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||
</div>
|
||||
<div class="field"><label>Switchport</label>
|
||||
<input type="text" name="port" placeholder="z.B. 3">
|
||||
<div class="field-hint">Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.</div>
|
||||
</div>
|
||||
<div class="field"><label>Switch (optional)</label>
|
||||
<select name="switch_hostname">
|
||||
<option value="">Kein Switch</option>
|
||||
{% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
||||
<div class="field"><label>SSH-Port</label>
|
||||
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zugangsdaten</label>
|
||||
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
|
||||
<option value="">Keine (nicht per SSH verwaltet)</option>
|
||||
{% for c in all_credentials %}
|
||||
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}">{{ c['name'] }} ({{ c['category'] }})</option>
|
||||
{% endfor %}
|
||||
<option value="new">+ Neue Zugangsdaten anlegen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="new-credential-fields hidden">
|
||||
<div class="field"><label>Name der Zugangsdaten</label>
|
||||
<input type="text" name="new_credential_name" placeholder="z.B. Linux-Clients">
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="new_credential_username" placeholder="z.B. admin">
|
||||
</div>
|
||||
<div class="field"><label>Passwort</label>
|
||||
<input type="password" name="new_credential_password">
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" name="new_credential_password_confirm">
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||
Verbindung testen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<!-- Modal: Bearbeiten -->
|
||||
<div class="modal-overlay" id="editDeviceModal">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
<form method="post" onsubmit="return validateDeviceForm(this);">
|
||||
<input type="hidden" name="edit_device" value="1">
|
||||
<input type="hidden" name="old_mac" id="edit_old_mac">
|
||||
<div class="modal-header">
|
||||
<h3>Gerät bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="name" id="edit_name" required>
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="ip" id="edit_ip" required>
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>MAC-Adresse</label>
|
||||
<input type="text" name="mac" id="edit_mac" required>
|
||||
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
|
||||
</div>
|
||||
<div class="field"><label>Switchport</label>
|
||||
<input type="text" name="port" id="edit_port">
|
||||
<div class="field-hint">Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.</div>
|
||||
</div>
|
||||
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
|
||||
<div class="field"><label>SSH-Port</label>
|
||||
<input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zugangsdaten</label>
|
||||
<select name="credential_choice" id="edit_credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
|
||||
<option value="">Keine (nicht per SSH verwaltet)</option>
|
||||
{% for c in all_credentials %}
|
||||
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}">{{ c['name'] }} ({{ c['category'] }})</option>
|
||||
{% endfor %}
|
||||
<option value="new">+ Neue Zugangsdaten anlegen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="new-credential-fields hidden">
|
||||
<div class="field"><label>Name der Zugangsdaten</label>
|
||||
<input type="text" name="new_credential_name" placeholder="z.B. Linux-Clients">
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="new_credential_username" placeholder="z.B. admin">
|
||||
</div>
|
||||
<div class="field"><label>Passwort</label>
|
||||
<input type="password" name="new_credential_password">
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" name="new_credential_password_confirm">
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||
Verbindung testen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Switch ändern -->
|
||||
<div class="modal-overlay" id="switchModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="edit_device" value="1">
|
||||
<input type="hidden" name="old_mac" id="switch_mac">
|
||||
<div class="modal-header">
|
||||
<h3>Switch ändern</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Zugeordneter Switch</label>
|
||||
<select name="switch_hostname" id="switch_select">
|
||||
<option value="">Kein Switch</option>
|
||||
{% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_create or can_edit %}
|
||||
<!-- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys
|
||||
und Prüfen der Zugangsdaten, bevor das Gerät gespeichert wird) -->
|
||||
<div class="modal-overlay" id="terminalModal">
|
||||
<div class="modal" style="max-width:720px;">
|
||||
<div class="modal-header">
|
||||
<h3>SSH-Verbindungstest</h3>
|
||||
<button type="button" class="modal-close" data-close-modal onclick="closeTerminal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:0;">
|
||||
<div class="term-toolbar">
|
||||
<div class="flex gap-2" style="align-items:center;">
|
||||
<span id="termStatus" class="pill unknown">Bereit</span>
|
||||
<span id="termTarget" class="text-faint mono" style="font-size:12px;"></span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="termPastePassword">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
Passwort einfügen
|
||||
</button>
|
||||
</div>
|
||||
<div id="terminal" class="xterm-container"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<p class="text-faint" style="font-size:11.5px; margin-right:auto;">
|
||||
Unbekannter Host-Key: "yes" bestätigt und merkt sich ihn dauerhaft.
|
||||
</p>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal onclick="closeTerminal()">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/vendor/xterm.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vendor/xterm-addon-fit.js') }}"></script>
|
||||
<script>
|
||||
function resetDeviceForm() {
|
||||
document.querySelector("#deviceModal form").reset();
|
||||
resetCredentialChoice("add");
|
||||
}
|
||||
function openEditDeviceModal(mac, name, ip, port, sshPort, credentialId) {
|
||||
document.getElementById("edit_old_mac").value = mac;
|
||||
document.getElementById("edit_name").value = name;
|
||||
document.getElementById("edit_ip").value = ip;
|
||||
document.getElementById("edit_mac").value = mac;
|
||||
document.getElementById("edit_port").value = port;
|
||||
document.getElementById("edit_ssh_port").value = sshPort || "";
|
||||
document.getElementById("edit_credential_choice").value = credentialId || "";
|
||||
PoeUI.openModal("editDeviceModal");
|
||||
resetCredentialChoice("edit");
|
||||
}
|
||||
function openSwitchModal(mac, switchHostname) {
|
||||
document.getElementById("switch_mac").value = mac;
|
||||
document.getElementById("switch_select").value = switchHostname;
|
||||
PoeUI.openModal("switchModal");
|
||||
}
|
||||
|
||||
const ipPattern = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
const macPattern = /^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$/;
|
||||
|
||||
function validateIP(input) {
|
||||
const ok = ipPattern.test(input.value);
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
return ok;
|
||||
}
|
||||
function validateMAC(input) {
|
||||
const ok = macPattern.test(input.value);
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
return ok;
|
||||
}
|
||||
function validateNewCredentialPassword(form) {
|
||||
const select = form.querySelector(".credential-select");
|
||||
if (!select || select.value !== "new") return true;
|
||||
const pass = form.querySelector("input[name='new_credential_password']");
|
||||
const confirm = form.querySelector("input[name='new_credential_password_confirm']");
|
||||
if (!pass || !confirm) return true;
|
||||
if (pass.value !== confirm.value) { confirm.classList.add("is-invalid"); return false; }
|
||||
confirm.classList.remove("is-invalid");
|
||||
return true;
|
||||
}
|
||||
function validateDeviceForm(form) {
|
||||
const ipInput = form.querySelector("input[name='ip']");
|
||||
const macInput = form.querySelector("input[name='mac']");
|
||||
let valid = true;
|
||||
if (ipInput) valid = validateIP(ipInput) && valid;
|
||||
if (macInput) valid = validateMAC(macInput) && valid;
|
||||
valid = validateNewCredentialPassword(form) && valid;
|
||||
return valid;
|
||||
}
|
||||
document.addEventListener("input", (e) => {
|
||||
if (e.target.name === "ip") validateIP(e.target);
|
||||
if (e.target.name === "mac") validateMAC(e.target);
|
||||
if (e.target.name === "new_credential_password_confirm") {
|
||||
const form = e.target.closest("form");
|
||||
const pass = form.querySelector("input[name='new_credential_password']");
|
||||
if (pass) e.target.classList.toggle("is-invalid", pass.value !== e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
function toggleNewCredentialFields(select) {
|
||||
const fields = select.closest(".modal-body").querySelector(".new-credential-fields");
|
||||
if (fields) fields.classList.toggle("hidden", select.value !== "new");
|
||||
}
|
||||
function resetCredentialChoice(id) {
|
||||
// Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend
|
||||
// zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach
|
||||
// vorherigem Umschalten auf "neu" ohne zu speichern).
|
||||
setTimeout(() => {
|
||||
const select = document.querySelector(`#${id === 'add' ? 'deviceModal' : 'editDeviceModal'} .credential-select`);
|
||||
if (select) toggleNewCredentialFields(select);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal) — identisch zum
|
||||
// Identisches Muster wie in switches.html.
|
||||
// -------------------------------------------------------------------------
|
||||
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
|
||||
|
||||
function ensureTerminal() {
|
||||
if (term) return;
|
||||
term = new Terminal({
|
||||
convertEol: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace",
|
||||
cursorBlink: true,
|
||||
theme: { background: "#0a0c10", foreground: "#c7ccd6" },
|
||||
});
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById("terminal"));
|
||||
fitAddon.fit();
|
||||
term.onData((data) => {
|
||||
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
|
||||
termSocket.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
|
||||
termSocket.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", () => fitAddon && fitAddon.fit());
|
||||
}
|
||||
|
||||
function setTermStatus(text, cls) {
|
||||
const el = document.getElementById("termStatus");
|
||||
el.className = "pill " + cls;
|
||||
el.innerText = text;
|
||||
}
|
||||
|
||||
function getCredentialInfo(form) {
|
||||
const select = form.querySelector(".credential-select");
|
||||
if (!select || select.value === "new") {
|
||||
return {
|
||||
username: (form.querySelector("input[name='new_credential_username']") || {}).value?.trim(),
|
||||
passwordInput: form.querySelector("input[name='new_credential_password']"),
|
||||
};
|
||||
}
|
||||
const option = select.selectedOptions[0];
|
||||
return { username: option ? option.dataset.username : null, passwordInput: null };
|
||||
}
|
||||
|
||||
function openTerminal(form) {
|
||||
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
|
||||
const port = parseInt((form.querySelector("input[name='ssh_port']") || {}).value, 10) || 22;
|
||||
const { username, passwordInput } = getCredentialInfo(form);
|
||||
activePasswordInput = passwordInput;
|
||||
|
||||
if (!host || !username) {
|
||||
showToast("Bitte IP-Adresse ausfüllen und Zugangsdaten auswählen/anlegen, bevor du die Verbindung testest.", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
PoeUI.openModal("terminalModal");
|
||||
ensureTerminal();
|
||||
term.reset();
|
||||
document.getElementById("termTarget").innerText = `${username}@${host}:${port}`;
|
||||
setTermStatus("Verbinde…", "unknown");
|
||||
setTimeout(() => fitAddon && fitAddon.fit(), 60);
|
||||
|
||||
if (termSocket) { try { termSocket.close(); } catch (e) {} }
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
termSocket = new WebSocket(`${proto}//${location.host}/ws/ssh_terminal`);
|
||||
|
||||
termSocket.onopen = () => {
|
||||
termSocket.send(JSON.stringify({ host, username, port }));
|
||||
setTermStatus("Verbunden", "online");
|
||||
};
|
||||
termSocket.onmessage = (event) => term.write(event.data);
|
||||
termSocket.onclose = () => setTermStatus("Getrennt", "offline");
|
||||
termSocket.onerror = () => setTermStatus("Fehler", "offline");
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
if (termSocket) {
|
||||
try { termSocket.close(); } catch (e) {}
|
||||
termSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
const termPasteBtn = document.getElementById("termPastePassword");
|
||||
if (termPasteBtn) {
|
||||
termPasteBtn.addEventListener("click", () => {
|
||||
if (!activePasswordInput || !activePasswordInput.value) {
|
||||
showToast("Kein Passwort bekannt — bitte manuell im Terminal eingeben.", "danger");
|
||||
return;
|
||||
}
|
||||
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
|
||||
showToast("Keine aktive Terminal-Verbindung.", "danger");
|
||||
return;
|
||||
}
|
||||
termSocket.send(JSON.stringify({ type: "input", data: activePasswordInput.value + "\n" }));
|
||||
});
|
||||
}
|
||||
|
||||
const terminalModalEl = document.getElementById("terminalModal");
|
||||
if (terminalModalEl) {
|
||||
terminalModalEl.addEventListener("click", (e) => {
|
||||
if (e.target.id === "terminalModal") closeTerminal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && terminalModalEl.classList.contains("open")) closeTerminal();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDevice(mac, checkbox) {
|
||||
checkbox.disabled = true;
|
||||
fetch(`/devices/toggle/${mac}`, { method: "POST" })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
checkbox.disabled = false;
|
||||
if (data.success) {
|
||||
checkbox.checked = data.new_status === 1;
|
||||
showToast(data.msg, "success");
|
||||
} else {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
showToast(data.msg, "danger");
|
||||
}
|
||||
})
|
||||
.catch(() => { checkbox.disabled = false; checkbox.checked = !checkbox.checked; showToast("Fehler beim Umschalten.", "danger"); });
|
||||
}
|
||||
|
||||
function filterTable(inputId, tableId) {
|
||||
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,420 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "groups" %}
|
||||
{% block page_title %}Gruppen{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{# Rendert einen Bereich (Geräte/Logs/Einstellungen) als kompakte, schmale
|
||||
Spalte: eigene Kopfzeile mit Bereichsname + einem Kästchen (der
|
||||
Kill-Switch, "Bereich sichtbar"), darunter eine kleine Tabelle mit den
|
||||
Unterpunkten (Clients, Switche, ...) als Zeilen und nur den für diesen
|
||||
Bereich tatsächlich genutzten Rechten (group_row_types[group_key]) als
|
||||
Spalten — Logs/Einstellungen zeigen z.B. nur R/W/E, nur Geräte zeigt
|
||||
zusätzlich D (PoE-Neustart über Dashboard, ausschließlich bei Clients).
|
||||
"Ändern" (E) deckt Löschen überall mit ab — es gibt nirgends ein eigenes
|
||||
Löschen-Recht. Zellen ohne passendes Recht für die jeweilige Zeile (z.B.
|
||||
"D" bei Switche/Zugangsdaten) werden als ausgegraute, nicht anklickbare
|
||||
Checkbox dargestellt statt zu fehlen — dadurch bleiben alle Spalten
|
||||
bündig untereinander. readonly=true zeigt nur den aktuellen Stand
|
||||
(Admin/Systemgruppen bzw. fehlendes groups.edit). Die JS-Funktion
|
||||
applyPermissionGating() sperrt Kind-Rechte, solange das Bereich-Lesen
|
||||
(Kill-Switch) nicht gesetzt ist. Mehrere Bereiche stehen per Flexbox
|
||||
nebeneinander (siehe .permission-groups-row), damit die ganze
|
||||
Rechteübersicht einer Gruppe in einer kompakten Zeile Platz hat. #}
|
||||
{% macro permission_table(group, group_key, checked_keys, readonly) %}
|
||||
{% set row_types = group_row_types[group_key] %}
|
||||
<div class="permission-group-col">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="permission-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="permission-group-header-cell">
|
||||
<label class="permission-group-toggle">
|
||||
<input type="checkbox" name="permissions" value="{{ group['view_key'] }}"
|
||||
title="{{ permission_labels.get(group['view_key'], group['label']) }}"
|
||||
{% if group['view_key'] in checked_keys %}checked{% endif %}
|
||||
{% if readonly %}disabled{% endif %}
|
||||
class="permission-area-toggle-cb">
|
||||
<span class="permission-group-name">{{ group['label'] }}</span>
|
||||
</label>
|
||||
</th>
|
||||
{% for row_key, row_letter, row_label in row_types %}
|
||||
<th title="{{ row_label }}">{{ row_letter }}</th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for child_key, child in group['children'].items() %}
|
||||
<tr>
|
||||
<td class="permission-row-label">{{ child['label'] }}</td>
|
||||
{% for row_key, row_letter, row_label in row_types %}
|
||||
{% set perm_key = child['rows'].get(row_key) %}
|
||||
<td>
|
||||
{% if perm_key %}
|
||||
<input type="checkbox" name="permissions" value="{{ perm_key }}"
|
||||
title="{{ permission_labels.get(perm_key, perm_key) }}"
|
||||
{% if perm_key in checked_keys %}checked{% endif %}
|
||||
{% if readonly %}disabled{% endif %}
|
||||
class="permission-child-cb">
|
||||
{% else %}
|
||||
<input type="checkbox" disabled class="permission-cb-na" tabindex="-1">
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{# compact=true (Gruppen-Dropdown auf der Hauptseite) verkleinert Zellen-
|
||||
Padding/Abstände gegenüber dem "Neue Gruppe"-Modal (compact=false, bleibt
|
||||
unverändert) — gleicher Aufbau, nur enger, da im Dropdown mehr Breite
|
||||
zur Verfügung steht als im schmalen Modal und es sonst auseinandergezogen wirkt. #}
|
||||
{% macro permission_tree(checked_keys, readonly, compact=false) %}
|
||||
<div class="permission-groups-row{{ ' permission-groups-row--compact' if compact }}">
|
||||
{% for group_key, group in permission_catalog.items() %}
|
||||
{{ permission_table(group, group_key, checked_keys, readonly) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="permission-legend">
|
||||
<strong>R</strong> = Read (Lesen) · <strong>W</strong> = Write (Anlegen) ·
|
||||
<strong>E</strong> = Edit (Ändern, inkl. Löschen — bei Im-/Export: Import ausführen) ·
|
||||
<strong>D</strong> = Dashboard (PoE-Neustart über das Dashboard-Popup, nur bei Clients) ·
|
||||
<strong>X</strong> = Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Gruppen</h2>
|
||||
<div class="hint">
|
||||
Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich.
|
||||
„Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has_permission('groups.create') %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neue Gruppe
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="groupSearch" placeholder="Gruppen durchsuchen…" oninput="filterGroupsTable()">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="groupsTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="name">Gruppe</th>
|
||||
<th data-sort-key="members">Mitglieder</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr></thead>
|
||||
|
||||
<!-- Virtuelle "Admin"-Gruppe: Rechte sind fix (alles), Mitgliedschaft
|
||||
wird direkt über is_admin gesteuert. Bleibt beim Sortieren immer
|
||||
oben (eigenes <tbody data-sort-pinned>). -->
|
||||
<tbody data-sort-pinned>
|
||||
<tr>
|
||||
<td class="cell-name">Admin <span class="pill admin">Systemrolle</span></td>
|
||||
<td class="text-dim">{{ admin_virtual_group.member_names|length }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="icon-btn" title="Rechte anzeigen" onclick="toggleDetail('detail-admin')">
|
||||
<svg id="chev-admin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="adminMembersModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="group-detail-row hidden" id="detail-admin">
|
||||
<td colspan="3">
|
||||
{{ permission_tree(admin_virtual_group.permissions, true, true) }}
|
||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
{% for g in groups %}
|
||||
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
|
||||
<!-- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen <tbody>, damit
|
||||
die Sortierung beide Zeilen gemeinsam verschiebt. -->
|
||||
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
|
||||
<tr>
|
||||
<td class="cell-name">
|
||||
{{ g.name }}
|
||||
{% if g.is_system %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-dim">{{ g.member_names|length }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
|
||||
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
|
||||
</button>
|
||||
{% if current_user.has_permission('groups.edit') and not g.is_default and not g.is_system %}
|
||||
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
|
||||
<input type="hidden" name="delete_group" value="{{ g.id }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% set can_unlock_system = g.is_system and current_user.is_admin %}
|
||||
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
|
||||
<td colspan="3">
|
||||
{% if can_edit_this %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="permissions_submitted" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
<input type="hidden" name="name" value="{{ g.name }}">
|
||||
{{ permission_tree(g.permissions, false, true) }}
|
||||
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<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>
|
||||
Rechte speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% elif can_unlock_system %}
|
||||
<div id="readonly-{{ g.id }}">
|
||||
{{ permission_tree(g.permissions, true, true) }}
|
||||
<div class="flex" style="justify-content:space-between; align-items:center; margin-top:12px;">
|
||||
<p class="text-faint" style="font-size:11.5px; margin:0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind normalerweise fest.</p>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="unlockSystemGroup({{ g.id }})">Freischalten</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="hidden" id="unlock-{{ g.id }}">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="permissions_submitted" value="1">
|
||||
<input type="hidden" name="unlock_system_group" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
<input type="hidden" name="name" value="{{ g.name }}">
|
||||
{{ permission_tree(g.permissions, false, true) }}
|
||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0;">
|
||||
⚠ Diese Gruppe ist die Standardgruppe für neue Benutzer (auch neu angelegte AD/LDAP-Konten). Zu restriktive
|
||||
Rechte hier können den Erst-Login neuer Konten einschränken.
|
||||
</p>
|
||||
<div class="flex" style="justify-content:flex-end;">
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<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>
|
||||
Rechte speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{{ permission_tree(g.permissions, true, true) }}
|
||||
{% if g.is_system %}
|
||||
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
{% else %}
|
||||
<tbody data-sort-pinned>
|
||||
<tr class="empty-row"><td colspan="3">Noch keine weiteren Gruppen angelegt.</td></tr>
|
||||
</tbody>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Admin-Mitglieder verwalten -->
|
||||
<div class="modal-overlay" id="adminMembersModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="assign_admins" value="1">
|
||||
<div class="modal-header">
|
||||
<h3>Admin-Mitglieder</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-faint" style="font-size:11.5px; margin:0 0 12px;">Mindestens ein Admin muss bestehen bleiben.</p>
|
||||
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
|
||||
{% for u in all_users_all %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['is_admin'] %}checked{% endif %}>
|
||||
{{ u['username'] }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals: Mitglieder pro Gruppe verwalten -->
|
||||
{% for g in groups %}
|
||||
<div class="modal-overlay" id="membersModal{{ loop.index }}">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_group" value="1">
|
||||
<input type="hidden" name="members_submitted" value="1">
|
||||
<input type="hidden" name="group_id" value="{{ g.id }}">
|
||||
<input type="hidden" name="name" value="{{ g.name }}">
|
||||
<div class="modal-header">
|
||||
<h3>Mitglieder — {{ g.name }}</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="check-list" style="max-height:320px; overflow-y:auto; padding-right:4px;">
|
||||
{% for u in all_users %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="members" value="{{ u['id'] }}" {% if u['id'] in g.members %}checked{% endif %}>
|
||||
{{ u['username'] }}
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Keine Nicht-Admin-Benutzer vorhanden.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Modal: Neue Gruppe — Rechte direkt beim Anlegen setzbar, Mitglieder
|
||||
werden danach über die Gruppentabelle zugeordnet (die Gruppe muss
|
||||
dafür erst existieren). -->
|
||||
<div class="modal-overlay" id="addGroupModal">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
<form method="post">
|
||||
<div class="modal-header">
|
||||
<h3>Neue Gruppe</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_group" value="1">
|
||||
<div class="field">
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Facility-Team">
|
||||
<div class="field-hint">Mitglieder werden danach über die Gruppentabelle zugeordnet.</div>
|
||||
</div>
|
||||
{{ permission_tree([], false) }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Filtert ganze <tbody>-Blöcke (Haupt- + Detail-Zeile je Gruppe gemeinsam,
|
||||
// analog zur Sortierung) statt einzelner <tr> -- die gepinnte Admin-Gruppe
|
||||
// (data-sort-pinned) bleibt dabei immer sichtbar, unabhängig vom Suchbegriff.
|
||||
function filterGroupsTable() {
|
||||
const q = document.getElementById("groupSearch").value.trim().toLowerCase();
|
||||
document.querySelectorAll("#groupsTable tbody").forEach(tbody => {
|
||||
if (tbody.hasAttribute("data-sort-pinned")) return;
|
||||
tbody.style.display = tbody.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
// Freischalten der Systemgruppe "Benutzer": zeigt statt der Nur-Lese-Ansicht
|
||||
// das editierbare Formular (inkl. unlock_system_group=1) -- serverseitig
|
||||
// erneut geprüft (admin + Flag), das hier ist nur die UI-Bestätigung.
|
||||
function unlockSystemGroup(id) {
|
||||
window.confirmAction(
|
||||
"Rechte der Standardgruppe „Benutzer“ wirklich bearbeiten? Diese Gruppe ist der Login-Fallback für neue Benutzer (auch neue AD/LDAP-Konten) — zu restriktive Rechte können deren Erst-Login einschränken.",
|
||||
() => {
|
||||
document.getElementById("readonly-" + id).classList.add("hidden");
|
||||
document.getElementById("unlock-" + id).classList.remove("hidden");
|
||||
},
|
||||
"Standardgruppe freischalten?"
|
||||
);
|
||||
}
|
||||
|
||||
function toggleDetail(id) {
|
||||
const row = document.getElementById(id);
|
||||
if (!row) return;
|
||||
row.classList.toggle("hidden");
|
||||
const chev = document.getElementById(id.replace("detail-", "chev-"));
|
||||
if (chev) chev.style.transform = row.classList.contains("hidden") ? "" : "rotate(180deg)";
|
||||
}
|
||||
|
||||
// Kind-Rechte einer Kategorie sind erst vergebbar, wenn das übergeordnete
|
||||
// "Bereich anzeigen"-Recht (Kill-Switch, erste Spalte/L-Zeile) gesetzt ist —
|
||||
// spiegelt serverseitig User.has_permission() (PERMISSION_PARENT_GROUP).
|
||||
// Bearbeitbare Tabellen bekommen einen Live-Listener, Nur-Lese-Tabellen
|
||||
// (Admin/Systemgruppen) bleiben unangetastet, deren Checkboxen sind ohnehin
|
||||
// alle disabled.
|
||||
function applyPermissionGating() {
|
||||
document.querySelectorAll(".permission-group-col").forEach(function (area) {
|
||||
const toggle = area.querySelector(".permission-area-toggle-cb");
|
||||
if (!toggle || toggle.disabled) return;
|
||||
const tbody = area.querySelector("tbody");
|
||||
const children = area.querySelectorAll(".permission-child-cb");
|
||||
const sync = function () {
|
||||
children.forEach(function (cb) {
|
||||
cb.disabled = !toggle.checked;
|
||||
if (!toggle.checked) cb.checked = false;
|
||||
});
|
||||
// Zusätzlich zum disabled-Attribut (das der Browser nur dezent
|
||||
// abblendet) sichtbar ausgrauen, wie gefordert — sonst fällt
|
||||
// "Bereich gesperrt" auf den ersten Blick kaum auf.
|
||||
if (tbody) tbody.classList.toggle("permission-locked", !toggle.checked);
|
||||
};
|
||||
toggle.addEventListener("change", sync);
|
||||
sync();
|
||||
});
|
||||
}
|
||||
|
||||
// R (erste Spalte/"Lesen") ist je Zeile Voraussetzung für W/E/D/X -- ein
|
||||
// Recht ohne R wäre sonst über die UI nicht erreichbar (z.B. Nav-Link/Seite
|
||||
// bleibt unsichtbar, obwohl das Kind-Recht technisch gesetzt ist). Abwahl
|
||||
// von R nimmt deshalb automatisch auch die anderen Spalten dieser Zeile mit,
|
||||
// Anwahl von W/E/D/X wählt automatisch R mit an.
|
||||
function applyRowViewPrerequisite() {
|
||||
document.querySelectorAll(".permission-table tbody tr").forEach(function (tr) {
|
||||
const boxes = Array.from(tr.querySelectorAll(".permission-child-cb"));
|
||||
if (boxes.length < 2) return;
|
||||
const viewBox = boxes[0];
|
||||
const restBoxes = boxes.slice(1);
|
||||
restBoxes.forEach(function (cb) {
|
||||
cb.addEventListener("change", function () {
|
||||
if (cb.checked && !viewBox.checked && !viewBox.disabled) {
|
||||
viewBox.checked = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
viewBox.addEventListener("change", function () {
|
||||
if (!viewBox.checked) {
|
||||
restBoxes.forEach(function (cb) { cb.checked = false; });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", applyPermissionGating);
|
||||
document.addEventListener("DOMContentLoaded", applyRowViewPrerequisite);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,327 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "index" %}
|
||||
{% block page_title %}Dashboard{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ device_count }} Geräte{{ " im Bestand" if current_user.is_authenticated else "" }}</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="search-input" style="max-width:320px; margin-bottom:18px;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="tileSearch" placeholder="Gerät suchen…">
|
||||
</div>
|
||||
|
||||
{% include "_dashboard_tiles.html" %}
|
||||
|
||||
{% if not current_user.is_authenticated %}
|
||||
<p class="text-faint mt-3" style="font-size:12px;">
|
||||
Nur Lese-Ansicht — deaktivierte Geräte werden nicht angezeigt.
|
||||
<a href="{{ url_for('login') }}" style="color:var(--accent-strong); font-weight:600;">Anmelden</a>,
|
||||
um alle Geräte zu verwalten oder einen PoE-Neustart auszulösen.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- Device detail modal -->
|
||||
<div class="modal-overlay" id="deviceModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3 id="deviceModalTitle">Gerät</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="detail-list">
|
||||
<div class="detail-row"><span class="k">IP-Adresse</span><span class="v mono" id="deviceIp">-</span></div>
|
||||
<div class="detail-row"><span class="k">Switch</span><span class="v" id="deviceSwitch">-</span></div>
|
||||
<div class="detail-row"><span class="k">Port</span><span class="v" id="devicePort">-</span></div>
|
||||
<div class="detail-row"><span class="k">Status</span><span class="v" id="deviceStatus">-</span></div>
|
||||
<div class="detail-row"><span class="k">Letzte Prüfung</span><span class="v" id="deviceChecked">-</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
{% if current_user.has_permission('devices.edit') %}
|
||||
<button type="button" class="btn btn-success" id="activateButton" style="display:none;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L20 7"/></svg>
|
||||
Aktivieren
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has_permission('devices.restart') %}
|
||||
<button type="button" class="btn btn-primary" id="restartButton" style="display:none;">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
Neustarten
|
||||
</button>
|
||||
{% endif %}
|
||||
<!-- Schließen bewusst als letztes Element im DOM: modal-footer nutzt
|
||||
justify-content:flex-end, damit landet der zuletzt gerenderte
|
||||
Button ganz rechts — Schließen soll unabhängig davon, welche
|
||||
Aktions-Buttons gerade sichtbar sind, immer rechts stehen. -->
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has_permission('devices.restart') %}
|
||||
<!-- Restart confirm modal -->
|
||||
<div class="modal-overlay" id="restartModal">
|
||||
<div class="modal" style="max-width:400px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="restartTitle">Neustart</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-dim">Der Neustart erfolgt durch einen kurzen PoE-Reset. Das Gerät wird für wenige Sekunden vom Netzwerk getrennt.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmRestart" style="background:var(--warning); color:#241a00;">Neustarten</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const isAuthenticated = {{ "true" if current_user.is_authenticated else "false" }};
|
||||
let selectedCard = null, selectedMac = null, selectedName = null;
|
||||
const restartButton = document.getElementById("restartButton");
|
||||
const activateButton = document.getElementById("activateButton");
|
||||
const searchInput = document.getElementById("tileSearch");
|
||||
|
||||
// Status-Filter über die Stat-Kacheln (Online/Offline/Deaktiviert/
|
||||
// Gesamt) — null = kein Filter aktiv ("Gesamt"). Zusammen mit dem
|
||||
// Suchfilter kombiniert in applyFilters(), damit beide gleichzeitig
|
||||
// greifen können (z.B. "offline" + Namenssuche).
|
||||
let activeStatusFilter = null;
|
||||
// Welche Abschnitte (per data-status) manuell eingeklappt wurden — per
|
||||
// Klick auf den Abschnittstitel, unabhängig vom Status-Filter. Bleibt
|
||||
// über Live-Updates hinweg erhalten (siehe applyCollapseState()).
|
||||
const collapsedSections = new Set();
|
||||
|
||||
// Kombinierter Filter über alle Abschnitte hinweg: ein Abschnitt wird
|
||||
// komplett ausgeblendet, wenn er nicht zum Status-Filter passt ODER
|
||||
// keine seiner Kacheln mehr zur Namenssuche passt. Wird nach jedem
|
||||
// Live-Update erneut angewendet, damit aktive Filter erhalten bleiben
|
||||
// (die alten DOM-Knoten werden beim Austausch verworfen).
|
||||
function applyFilters() {
|
||||
const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
|
||||
let anyVisible = false;
|
||||
document.querySelectorAll(".dash-section").forEach(section => {
|
||||
const statusMatches = !activeStatusFilter || section.dataset.status === activeStatusFilter;
|
||||
let sectionHasMatch = false;
|
||||
section.querySelectorAll(".device-card").forEach(card => {
|
||||
const match = statusMatches && card.dataset.name.toLowerCase().includes(q);
|
||||
card.classList.toggle("hidden", !match);
|
||||
if (match) sectionHasMatch = true;
|
||||
});
|
||||
section.classList.toggle("hidden", !sectionHasMatch);
|
||||
if (sectionHasMatch) anyVisible = true;
|
||||
});
|
||||
const noResults = document.getElementById("noSearchResults");
|
||||
if (noResults) noResults.classList.toggle("hidden", anyVisible || (q === "" && !activeStatusFilter));
|
||||
// Aktive Stat-Kachel synchron halten — läuft hier statt nur im
|
||||
// Klick-Handler, damit der Zustand auch nach einem Live-Update
|
||||
// (frische, neu eingefügte Stat-Kacheln) sofort wieder stimmt.
|
||||
document.querySelectorAll(".stat-card[data-status-filter]").forEach(c => {
|
||||
const isTotal = c.dataset.statusFilter === "";
|
||||
const active = activeStatusFilter === null ? isTotal : c.dataset.statusFilter === activeStatusFilter;
|
||||
c.classList.toggle("active", active);
|
||||
});
|
||||
applyCollapseState();
|
||||
}
|
||||
if (searchInput) searchInput.addEventListener("input", applyFilters);
|
||||
|
||||
// Ein-/Ausgeklappt-Zustand je Abschnitt anhand von collapsedSections
|
||||
// anwenden — separat von der Sichtbarkeit oben: "hidden" blendet den
|
||||
// ganzen Abschnitt (inkl. Titel) aus, "collapsed" versteckt nur das
|
||||
// Kachel-Raster darunter, der Titel bleibt klickbar sichtbar.
|
||||
function applyCollapseState() {
|
||||
document.querySelectorAll(".dash-section").forEach(section => {
|
||||
section.classList.toggle("collapsed", collapsedSections.has(section.dataset.status));
|
||||
});
|
||||
}
|
||||
|
||||
// Stat-Kacheln (Online/Offline/Deaktiviert/Gesamt): Klick filtert das
|
||||
// Dashboard auf genau diesen Status; erneuter Klick auf die bereits
|
||||
// aktive Kachel (oder auf "Gesamt") hebt den Filter wieder auf.
|
||||
function bindStatCards() {
|
||||
document.querySelectorAll(".stat-card[data-status-filter]").forEach(card => {
|
||||
card.addEventListener("click", function () {
|
||||
const key = this.dataset.statusFilter;
|
||||
activeStatusFilter = (!key || activeStatusFilter === key) ? null : key;
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Abschnittstitel (z.B. "Offline"): Klick klappt nur diesen Abschnitt
|
||||
// ein/aus, unabhängig vom Status-Filter oben.
|
||||
function bindSectionToggles() {
|
||||
document.querySelectorAll(".dash-section-title").forEach(title => {
|
||||
title.addEventListener("click", function () {
|
||||
const key = this.closest(".dash-section").dataset.status;
|
||||
if (collapsedSections.has(key)) collapsedSections.delete(key); else collapsedSections.add(key);
|
||||
applyCollapseState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Klick-Verhalten der Kacheln (Detail-Modal, Neustart/Aktivieren) — wird
|
||||
// nach jedem Live-Update erneut auf die frisch eingefügten Kacheln
|
||||
// angewendet, da deren alte DOM-Knoten beim Austausch verworfen werden.
|
||||
function bindDeviceCards() {
|
||||
if (!isAuthenticated) return;
|
||||
document.querySelectorAll(".device-card").forEach(card => {
|
||||
const mac = card.dataset.mac;
|
||||
const restartKey = "restart_" + mac;
|
||||
if (sessionStorage.getItem(restartKey)) {
|
||||
const pill = card.querySelector(".pill");
|
||||
if (pill && pill.innerText.trim().toLowerCase() === "online") {
|
||||
sessionStorage.removeItem(restartKey);
|
||||
} else if (pill) {
|
||||
pill.innerHTML = '<span class="spinner"></span> Restarting…';
|
||||
}
|
||||
}
|
||||
card.addEventListener("click", function () {
|
||||
if (sessionStorage.getItem("restart_" + this.dataset.mac)) return;
|
||||
selectedCard = this;
|
||||
selectedMac = this.dataset.mac;
|
||||
selectedName = this.dataset.name;
|
||||
const isActive = this.dataset.active != "0";
|
||||
document.getElementById("deviceModalTitle").innerText = this.dataset.name;
|
||||
document.getElementById("deviceIp").innerText = this.dataset.ip || "-";
|
||||
document.getElementById("deviceSwitch").innerText = this.dataset.switch || "-";
|
||||
document.getElementById("devicePort").innerText = this.dataset.port || "-";
|
||||
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
|
||||
const pill = this.querySelector(".pill");
|
||||
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
|
||||
if (restartButton) {
|
||||
// Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset
|
||||
// auslösen (siehe restart_device()/poe.sh) — der Button wird für
|
||||
// diese Geräte komplett ausgeblendet statt nur ausgegraut, damit
|
||||
// kein Neustart vorgegaukelt wird, der tatsächlich nichts bewirkt.
|
||||
const hasSwitchAndPort = this.dataset.switch !== "-" && this.dataset.port !== "-";
|
||||
restartButton.style.display = (isActive && hasSwitchAndPort) ? "" : "none";
|
||||
restartButton.disabled = false;
|
||||
restartButton.title = "";
|
||||
}
|
||||
if (activateButton) activateButton.style.display = isActive ? "none" : "";
|
||||
PoeUI.openModal("deviceModal");
|
||||
});
|
||||
});
|
||||
}
|
||||
bindDeviceCards();
|
||||
bindStatCards();
|
||||
bindSectionToggles();
|
||||
applyFilters();
|
||||
|
||||
// Live-Update der Dashboard-Kacheln ohne vollen Seiten-Reload: tauscht
|
||||
// nur den #dashboard-tiles-Container gegen das AJAX-Partial aus (siehe
|
||||
// /dashboard/tiles) und hält dabei Suchfilter, Scroll-Position und
|
||||
// Sidebar-Zustand unangetastet. Läuft für an- und abgemeldete Ansicht.
|
||||
let refreshTimer = null;
|
||||
|
||||
function scheduleNextRefresh(overrideMs) {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
if (overrideMs) { refreshTimer = setTimeout(refreshDashboardTiles, overrideMs); return; }
|
||||
const pill = document.getElementById("global-timer-pill");
|
||||
const intervalMs = pill ? parseInt(pill.dataset.intervalMs, 10) : NaN;
|
||||
if (!intervalMs) return;
|
||||
const lastRunMs = parseInt(pill.dataset.lastRunMs, 10);
|
||||
// + kleiner Puffer, bis poe.sh die neue Zeile tatsächlich geschrieben hat.
|
||||
let waitMs = intervalMs + 1500;
|
||||
if (!isNaN(lastRunMs) && lastRunMs > 0) {
|
||||
const elapsed = Date.now() - lastRunMs;
|
||||
waitMs = (intervalMs - (((elapsed % intervalMs) + intervalMs) % intervalMs)) + 1500;
|
||||
}
|
||||
refreshTimer = setTimeout(refreshDashboardTiles, waitMs);
|
||||
}
|
||||
|
||||
function refreshDashboardTiles() {
|
||||
fetch("{{ url_for('dashboard_tiles') }}")
|
||||
.then(r => r.text())
|
||||
.then(html => {
|
||||
const container = document.getElementById("dashboard-tiles");
|
||||
if (!container) return;
|
||||
const temp = document.createElement("div");
|
||||
temp.innerHTML = html;
|
||||
const fresh = temp.querySelector("#dashboard-tiles");
|
||||
if (!fresh) return;
|
||||
container.replaceWith(fresh);
|
||||
// Globalen Topbar-Timer mit dem tatsächlichen Zeitpunkt
|
||||
// dieses Durchlaufs synchronisieren (initCheckTimer() in
|
||||
// app.js liest das Attribut bei jedem Tick neu ein).
|
||||
const pill = document.getElementById("global-timer-pill");
|
||||
if (pill) pill.dataset.lastRunMs = fresh.dataset.lastRunMs || "";
|
||||
bindDeviceCards();
|
||||
bindStatCards();
|
||||
bindSectionToggles();
|
||||
applyFilters();
|
||||
scheduleNextRefresh();
|
||||
})
|
||||
.catch(() => scheduleNextRefresh(15000));
|
||||
}
|
||||
|
||||
scheduleNextRefresh();
|
||||
document.addEventListener("poe:check-triggered", refreshDashboardTiles);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (restartButton) {
|
||||
restartButton.addEventListener("click", function () {
|
||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||
document.getElementById("restartTitle").innerText = selectedName + " neu starten?";
|
||||
PoeUI.openModal("restartModal");
|
||||
});
|
||||
}
|
||||
|
||||
const confirmRestart = document.getElementById("confirmRestart");
|
||||
if (confirmRestart) {
|
||||
confirmRestart.addEventListener("click", function () {
|
||||
const button = this;
|
||||
button.disabled = true;
|
||||
fetch("/restart/" + encodeURIComponent(selectedMac), { method: "POST" })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
button.disabled = false;
|
||||
if (!data.success) {
|
||||
showToast(data.message || "Neustart konnte nicht gestartet werden.", "danger");
|
||||
return;
|
||||
}
|
||||
PoeUI.closeModal(document.getElementById("restartModal"));
|
||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||
sessionStorage.setItem("restart_" + selectedMac, "1");
|
||||
const pill = selectedCard.querySelector(".pill");
|
||||
if (pill) pill.innerHTML = '<span class="spinner"></span> Restarting…';
|
||||
showToast(`Neustart von ${data.device} gestartet.`, "success");
|
||||
})
|
||||
.catch(err => { button.disabled = false; showToast("Fehler beim Starten des Neustarts.", "danger"); });
|
||||
});
|
||||
}
|
||||
|
||||
if (activateButton) {
|
||||
activateButton.addEventListener("click", function () {
|
||||
const button = this;
|
||||
button.disabled = true;
|
||||
fetch("/devices/toggle/" + encodeURIComponent(selectedMac), { method: "POST" })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
button.disabled = false;
|
||||
if (!data.success) {
|
||||
showToast(data.msg || "Aktivieren fehlgeschlagen.", "danger");
|
||||
return;
|
||||
}
|
||||
PoeUI.closeModal(document.getElementById("deviceModal"));
|
||||
showToast(data.msg, "success");
|
||||
refreshDashboardTiles();
|
||||
})
|
||||
.catch(() => { button.disabled = false; showToast("Fehler beim Aktivieren.", "danger"); });
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login · TESM</title>
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='images/icon-dark.svg') }}" id="app-favicon">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="login-page">
|
||||
<div class="login-branding">
|
||||
<img src="{{ url_for('static', filename='images/logo-dark-subline.svg') }}" alt="TESM" id="login-bg-logo">
|
||||
</div>
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="WiS">
|
||||
</div>
|
||||
<div style="display:flex; justify-content:center; margin-bottom:4px;">
|
||||
<img id="sidebar-logo" src="{{ url_for('static', filename='images/logo-dark.svg') }}" alt="TESM" style="height:32px; width:auto;">
|
||||
</div>
|
||||
<p class="login-sub">Melde dich an, um fortzufahren</p>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="field" style="margin-bottom:18px;">
|
||||
{% for message in messages %}
|
||||
<div class="toast danger" style="position:static; animation:none;">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post" data-no-unsaved-guard>
|
||||
<div class="field">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" autocomplete="username" required autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block" style="margin-top:6px;">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Live{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub" data-log-name>{{ log_name or "kein Logfile" }}</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Live-Log</h2>
|
||||
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline).</div>
|
||||
</div>
|
||||
<button id="refresh-btn" class="btn btn-secondary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="log-shell">
|
||||
<div class="log-toolbar">
|
||||
<div class="log-dots"><span></span><span></span><span></span></div>
|
||||
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
|
||||
</div>
|
||||
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function colorizeLine(line) {
|
||||
let cls = "";
|
||||
if (line.includes(" ist erreichbar!")) cls = "online";
|
||||
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
|
||||
else if (line.startsWith("----")) cls = "sep";
|
||||
// Neustart-bezogene Zeilen (manueller Neustart, automatischer PoE-Restart
|
||||
// bei Ausfall) einheitlich orange markieren.
|
||||
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
|
||||
const span = document.createElement("span");
|
||||
span.className = "log-line" + (cls ? " " + cls : "");
|
||||
span.textContent = line;
|
||||
return span;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const intervalMinutes = {{ global_check_interval | int }};
|
||||
const intervalMilliseconds = intervalMinutes * 60 * 1000;
|
||||
|
||||
function renderLog(text, logName) {
|
||||
const box = document.getElementById("log-box");
|
||||
box.innerHTML = "";
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
box.appendChild(colorizeLine(line));
|
||||
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
|
||||
});
|
||||
box.scrollTop = box.scrollHeight;
|
||||
// Dateiname mit anzeigen — ändert sich nach einem Service-Neustart
|
||||
// (neues Logfile, z.B. durch "Jetzt prüfen") ohne Seiten-Reload.
|
||||
if (logName) {
|
||||
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
|
||||
}
|
||||
}
|
||||
|
||||
function fetchLog() {
|
||||
fetch("{{ url_for('get_log') }}")
|
||||
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"))))
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
|
||||
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
|
||||
document.addEventListener("poe:check-triggered", fetchLog);
|
||||
fetchLog();
|
||||
if (intervalMilliseconds) setInterval(fetchLog, intervalMilliseconds);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "logs" %}
|
||||
{% block page_title %}Kea-DHCP{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Log-Ausgabe des kea-dhcp4-server-Dienstes</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Kea-DHCP-Log</h2>
|
||||
<div class="hint">Eigene Log-Datei des DHCP-Dienstes (siehe Systemeinstellungen → Logs für Rotation/Aufbewahrung).</div>
|
||||
</div>
|
||||
<button id="refresh-btn" class="btn btn-secondary">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="log-shell">
|
||||
<div class="log-toolbar">
|
||||
<div class="log-dots"><span></span><span></span><span></span></div>
|
||||
<span class="text-faint mono" style="font-size:11.5px;">kea-dhcp4.log</span>
|
||||
</div>
|
||||
<div id="log-box">{{ log_content or "Keine Logdatei gefunden." }}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function colorizeLine(line) {
|
||||
let cls = "";
|
||||
// Kea-Zeilenformat: "<ts> <SEVERITY> [logger/pid.tid] MESSAGE_ID text".
|
||||
// Grobe Einfärbung nach Schweregrad, ohne die Logik des Live-Logs
|
||||
// (online/offline) hier künstlich nachzubilden.
|
||||
if (/\s(FATAL|ERROR)\s/.test(line)) cls = "offline";
|
||||
else if (/\sWARN\s/.test(line)) cls = "restart";
|
||||
const span = document.createElement("span");
|
||||
span.className = "log-line" + (cls ? " " + cls : "");
|
||||
span.textContent = line;
|
||||
return span;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
function renderLog(text) {
|
||||
const box = document.getElementById("log-box");
|
||||
box.innerHTML = "";
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line, i) => {
|
||||
box.appendChild(colorizeLine(line));
|
||||
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
|
||||
});
|
||||
box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function fetchLog() {
|
||||
fetch("{{ url_for('get_kea_log') }}")
|
||||
.then(r => r.text().then(renderLog))
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
|
||||
document.getElementById("refresh-btn").addEventListener("click", fetchLog);
|
||||
fetchLog();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "maintenance" %}
|
||||
{% block page_title %}Wartung{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Wartung (SSH)</h2>
|
||||
<div class="hint">
|
||||
Bulk-Update (<code>apt update & upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
|
||||
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
|
||||
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not devices %}
|
||||
<p class="text-faint" style="font-size:12.5px;">
|
||||
Noch keine Geräte für die Wartung konfiguriert. Lege unter
|
||||
<a href="{{ url_for('devices') }}" style="color:var(--accent-strong); font-weight:600;">Clients</a>
|
||||
SSH-Zugangsdaten der Kategorie „Linux-Client“ an einem Gerät an — es erscheint danach automatisch hier.
|
||||
</p>
|
||||
{% else %}
|
||||
|
||||
<form method="post" action="{{ url_for('maintenance_update') }}" id="updateForm">
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="maintenanceSearch" placeholder="Geräte durchsuchen…" oninput="filterTable('maintenanceSearch','maintenanceTable')">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="maintenanceTable" data-sortable>
|
||||
<thead><tr>
|
||||
{% if can_run %}<th style="width:1%;"><input type="checkbox" id="selectAll" title="Alle auswählen"></th>{% endif %}
|
||||
<th data-sort-key="name">Name</th>
|
||||
<th data-sort-key="ip">IP-Adresse</th>
|
||||
<th data-sort-key="user">SSH-User</th>
|
||||
<th data-sort-key="status">Status</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for d in devices %}
|
||||
{% set job = jobs.get(d['mac']) %}
|
||||
<tr data-mac="{{ d['mac'] }}" data-sort-name="{{ d['name']|lower }}" data-sort-ip="{{ d['ip'] }}" data-sort-user="{{ d['cred_username']|lower }}">
|
||||
{% if can_run %}
|
||||
<td><input type="checkbox" name="macs" value="{{ d['mac'] }}" class="maint-check" {{ 'disabled' if not d['is_active'] else '' }}></td>
|
||||
{% endif %}
|
||||
<td class="cell-name">{{ d['name'] }}{% if not d['is_active'] %} <span class="text-faint" style="font-size:11px;">(deaktiviert)</span>{% endif %}</td>
|
||||
<td class="mono">{{ d['ip'] }}:{{ d['ssh_port'] or 22 }}</td>
|
||||
<td class="mono">{{ d['cred_username'] }}</td>
|
||||
<td class="maint-status">
|
||||
{% if job %}
|
||||
{% if job.status == 'running' %}<span class="pill unknown"><span class="spinner"></span> {{ job.message }}</span>
|
||||
{% elif job.status == 'success' %}<span class="pill online">{{ job.message }}</span>
|
||||
{% else %}<span class="pill offline">{{ job.message }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="pill disabled">Noch keine Aktion</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button type="button" class="icon-btn" title="Ausgabe anzeigen/verbergen" onclick="toggleJobOutput('{{ d['mac'] }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
{% if can_run %}
|
||||
<form method="post" action="{{ url_for('maintenance_reboot') }}" data-confirm="{{ d['name'] }} ({{ d['ip'] }}) jetzt per SSH neu starten?">
|
||||
<input type="hidden" name="macs" value="{{ d['mac'] }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">Neustart</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="job-output-row" id="joboutput-{{ d['mac'] }}" style="display:none;">
|
||||
<td colspan="{{ 6 if can_run else 5 }}">
|
||||
<pre class="job-output-pre">{{ job.output if job and job.output else 'Keine Ausgabe.' }}</pre>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can_run %}
|
||||
<div class="section-head" style="margin-top:16px;">
|
||||
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet).</div>
|
||||
<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="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
|
||||
Update starten
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById("selectAll")?.addEventListener("change", function () {
|
||||
document.querySelectorAll(".maint-check").forEach(function (cb) {
|
||||
if (!cb.disabled) cb.checked = this.checked;
|
||||
}, this);
|
||||
});
|
||||
|
||||
function filterTable(inputId, tableId) {
|
||||
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||
// Ausgabe-Zeile bleibt ausschließlich über toggleJobOutput() gesteuert,
|
||||
// sonst könnte ein zufälliger Text-Treffer darin sie unabhängig vom
|
||||
// dazugehörigen Geräte-Namen ein-/ausblenden.
|
||||
if (row.classList.contains("empty-row") || row.classList.contains("job-output-row")) return;
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function toggleJobOutput(mac) {
|
||||
const row = document.getElementById("joboutput-" + mac);
|
||||
if (row) row.style.display = (row.style.display === "none" || !row.style.display) ? "table-row" : "none";
|
||||
}
|
||||
|
||||
function statusPillHtml(job) {
|
||||
if (!job) return '<span class="pill disabled">Noch keine Aktion</span>';
|
||||
const msg = job.message || "";
|
||||
if (job.status === "running") return '<span class="pill unknown"><span class="spinner"></span> ' + msg + '</span>';
|
||||
if (job.status === "success") return '<span class="pill online">' + msg + '</span>';
|
||||
return '<span class="pill offline">' + msg + '</span>';
|
||||
}
|
||||
|
||||
let maintPollTimer = null;
|
||||
function pollMaintenanceStatus() {
|
||||
fetch("{{ url_for('maintenance_status') }}")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (jobs) {
|
||||
let anyRunning = false;
|
||||
document.querySelectorAll("tr[data-mac]").forEach(function (row) {
|
||||
const mac = row.dataset.mac;
|
||||
const job = jobs[mac];
|
||||
const cell = row.querySelector(".maint-status");
|
||||
if (cell) cell.innerHTML = statusPillHtml(job);
|
||||
const outRow = document.getElementById("joboutput-" + mac);
|
||||
if (outRow && job) {
|
||||
const pre = outRow.querySelector(".job-output-pre");
|
||||
if (pre) pre.textContent = job.output || "Keine Ausgabe.";
|
||||
}
|
||||
if (job && job.status === "running") anyRunning = true;
|
||||
});
|
||||
clearTimeout(maintPollTimer);
|
||||
maintPollTimer = setTimeout(pollMaintenanceStatus, anyRunning ? 2000 : 8000);
|
||||
})
|
||||
.catch(function () { maintPollTimer = setTimeout(pollMaintenanceStatus, 8000); });
|
||||
}
|
||||
pollMaintenanceStatus();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,214 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "papierkorb" %}
|
||||
{% block page_title %}Papierkorb{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Papierkorb</h2>
|
||||
<div class="hint">
|
||||
Gelöschte Clients, Switche, Zugangsdaten, Benutzer und Gruppen — werden nach
|
||||
{{ trash_retention_days }} Tag{{ 'en' if trash_retention_days != 1 else '' }} automatisch endgültig gelöscht
|
||||
(einstellbar unter Systemeinstellungen). AD/LDAP-Benutzer landen nie hier, da sie sich bei jedem gültigen
|
||||
Login automatisch neu anlegen.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||
|
||||
{% if current_user.has_permission('devices.view') %}
|
||||
<div class="card card-pad">
|
||||
<h3 style="font-size:14px; margin:0 0 12px;">Clients ({{ trash.devices|length }})</h3>
|
||||
{% if trash.devices %}
|
||||
<div class="table-wrap"><div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Name</th><th>IP</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in trash.devices %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ d['name'] }}</td>
|
||||
<td class="mono">{{ d['ip'] }}</td>
|
||||
<td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td>
|
||||
<td>
|
||||
{% if current_user.has_permission('devices.edit') %}
|
||||
<div class="row-actions">
|
||||
<form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('purge_device', mac=d['mac']) }}" data-confirm="„{{ d['name'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has_permission('switches.view') %}
|
||||
<div class="card card-pad">
|
||||
<h3 style="font-size:14px; margin:0 0 12px;">Switche ({{ trash.switches|length }})</h3>
|
||||
{% if trash.switches %}
|
||||
<div class="table-wrap"><div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Hostname</th><th>IP</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in trash.switches %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ s['hostname'] }}</td>
|
||||
<td class="mono">{{ s['ip'] }}</td>
|
||||
<td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td>
|
||||
<td>
|
||||
{% if current_user.has_permission('switches.edit') %}
|
||||
<div class="row-actions">
|
||||
<form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('purge_switch', hostname=s['hostname']) }}" data-confirm="„{{ s['hostname'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has_permission('credentials.view') %}
|
||||
<div class="card card-pad">
|
||||
<h3 style="font-size:14px; margin:0 0 12px;">Zugangsdaten ({{ trash.credentials|length }})</h3>
|
||||
{% if trash.credentials %}
|
||||
<div class="table-wrap"><div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Name</th><th>Username</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in trash.credentials %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ c['name'] }}</td>
|
||||
<td class="mono">{{ c['username'] }}</td>
|
||||
<td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td>
|
||||
<td>
|
||||
{% if current_user.has_permission('credentials.edit') %}
|
||||
<div class="row-actions">
|
||||
<form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('purge_credential', cred_id=c['id']) }}" data-confirm="„{{ c['name'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has_permission('users.view') %}
|
||||
<div class="card card-pad">
|
||||
<h3 style="font-size:14px; margin:0 0 12px;">Benutzer ({{ trash.users|length }})</h3>
|
||||
{% if trash.users %}
|
||||
<div class="table-wrap"><div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Username</th><th>Name</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for u in trash.users %}
|
||||
{% set full_name = [u['first_name'], u['last_name']]|select|join(' ') %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ u['username'] }}</td>
|
||||
<td class="text-dim">{{ full_name or '—' }}</td>
|
||||
<td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td>
|
||||
<td>
|
||||
{% if current_user.has_permission('users.edit') %}
|
||||
<div class="row-actions">
|
||||
<form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('purge_user', user_id=u['id']) }}" data-confirm="„{{ u['username'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has_permission('groups.view') %}
|
||||
<div class="card card-pad">
|
||||
<h3 style="font-size:14px; margin:0 0 12px;">Gruppen ({{ trash.groups|length }})</h3>
|
||||
{% if trash.groups %}
|
||||
<div class="table-wrap"><div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Name</th><th>Gelöscht am</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for g in trash.groups %}
|
||||
<tr>
|
||||
<td class="cell-name">{{ g['name'] }}</td>
|
||||
<td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td>
|
||||
<td>
|
||||
{% if current_user.has_permission('groups.edit') %}
|
||||
<div class="row-actions">
|
||||
<form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}">
|
||||
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('purge_group', group_id=g['id']) }}" data-confirm="„{{ g['name'] }}“ endgültig löschen? Das kann nicht rückgängig gemacht werden.">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Endgültig löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12px;">Papierkorb ist leer.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,306 @@
|
||||
{% 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 %}
|
||||
<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>
|
||||
{% if current_user.has_permission('settings_system.edit') %}
|
||||
<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</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 class="field-hint">Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.</div>
|
||||
</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>
|
||||
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="interval">Prüfintervall (Minuten)</label>
|
||||
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
|
||||
<div class="field-hint">Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (rpi-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
|
||||
</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>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>
|
||||
<div class="field">
|
||||
<label>Prüfintervall (Minuten)</label>
|
||||
<input type="number" value="{{ interval }}" disabled>
|
||||
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</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:16px; 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 %}
|
||||
{% if net_state %}
|
||||
<span class="mono text-faint" style="font-size:12px;">
|
||||
{{ net_interface }}
|
||||
{% if net_state.ok %}— {{ net_state.ip }}/{{ net_state.prefix }}{% if net_state.gateway %}, Gateway {{ net_state.gateway }}{% endif %}{% endif %}
|
||||
</span>
|
||||
<span class="pill {{ 'user' if net_state.mode == 'static' else ('online' if net_state.mode == 'dhcp' else 'unknown') }}">
|
||||
{{ {'static': 'Statisch', 'dhcp': 'DHCP', 'unknown': 'Modus unbekannt'}[net_state.mode] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if net_state and net_state.dns %}
|
||||
<div class="text-faint" style="font-size:12px; margin-bottom:16px;">Aktuelle DNS-Server: <span class="mono">{{ net_state.dns|join(', ') }}</span></div>
|
||||
{% endif %}
|
||||
|
||||
{% 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</label>
|
||||
<select name="net_interface">
|
||||
{% 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" 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" 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" 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</label>
|
||||
<input type="text" name="net_dns" 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 class="field-hint">Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.</div>
|
||||
</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)</label>
|
||||
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
|
||||
<div class="field-hint">Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).</div>
|
||||
</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)</label>
|
||||
<input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
|
||||
<div class="field-hint">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).</div>
|
||||
</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);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,635 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "settings_dhcp" %}
|
||||
{% set can_edit = current_user.has_permission('settings_dhcp.edit') %}
|
||||
{% block page_title %}DHCP{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-grid">
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">DHCP-Server-Status</h2>
|
||||
<div class="hint">Installation/Dienststeuerung nur über die Buttons unten.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if status.installed %}
|
||||
<div class="flex gap-2" style="align-items:center; margin-bottom:10px; flex-wrap:wrap;">
|
||||
<span class="pill online">Installiert</span>
|
||||
{% if status.active is sameas true %}
|
||||
<span class="pill online">Dienst aktiv</span>
|
||||
{% elif status.active is sameas false %}
|
||||
<span class="pill offline">Dienst inaktiv</span>
|
||||
{% else %}
|
||||
<span class="pill unknown">Dienst-Status unbekannt</span>
|
||||
{% endif %}
|
||||
{% if status.enabled is sameas true %}<span class="pill user">Autostart an</span>{% endif %}
|
||||
</div>
|
||||
{% if status.version %}<div class="text-faint mono" style="font-size:12px; margin-bottom:12px;">{{ status.version }}</div>{% endif %}
|
||||
{% if can_edit %}
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;">
|
||||
<form method="post" data-confirm="{{ dhcp_service }} aktivieren und (neu) starten? Übernimmt die zuletzt geschriebene Konfiguration.">
|
||||
<input type="hidden" name="dhcp_service_action" value="enable_restart">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Aktivieren & (neu) starten</button>
|
||||
</form>
|
||||
{% if status.active is sameas true %}
|
||||
<form method="post" data-confirm="{{ dhcp_service }} stoppen und deaktivieren? Startet nach einem Host-Neustart dann nicht automatisch wieder.">
|
||||
<input type="hidden" name="dhcp_service_action" value="stop">
|
||||
<button type="submit" class="btn btn-secondary btn-sm" style="color:var(--danger);">Stoppen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="pill disabled">Nicht installiert</span>
|
||||
<p class="text-faint" style="font-size:11.5px; margin:10px 0;">
|
||||
Kein <code>kea-dhcp4</code> auf diesem Host gefunden.
|
||||
</p>
|
||||
{% if can_edit %}
|
||||
<form method="post" data-confirm="{{ dhcp_package }} jetzt installieren (apt-get)? Startet den Dienst noch nicht.">
|
||||
<input type="hidden" name="install_dhcp_package" value="1">
|
||||
<button type="submit" class="btn btn-primary btn-sm">{{ dhcp_package }} installieren</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Globale DHCP-Einstellungen</h2>
|
||||
<div class="hint">Gelten für den gesamten Dienst, unabhängig von den Subnetzen unten.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_dhcp_config" value="1">
|
||||
<div class="field"><label>Domain</label>
|
||||
<input type="text" name="dhcp_domain" value="{{ cfg.dhcp_domain }}">
|
||||
</div>
|
||||
<div class="field"><label>Lease-Zeit Standard (Sek.)</label>
|
||||
<input type="number" name="dhcp_lease_default" value="{{ cfg.dhcp_lease_default }}">
|
||||
</div>
|
||||
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
|
||||
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
|
||||
</div>
|
||||
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label>
|
||||
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
|
||||
<div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Nach dem Schreiben den Dienst per Button neu starten, damit die Änderung wirksam wird.</div>
|
||||
</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>
|
||||
Konfiguration speichern
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „DHCP ändern“.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Subnetze</h2>
|
||||
<div class="hint">
|
||||
Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range,
|
||||
optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.
|
||||
</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Subnetz hinzufügen
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if subnets %}
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Interface</th><th>Range</th><th>Gateway</th><th>DNS</th><th>Erkanntes Netz</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in subnets %}
|
||||
{% set st = subnet_status.get(s.id) %}
|
||||
<tr {% if not s.enabled %}style="opacity:.55;"{% endif %}>
|
||||
<td class="mono">{{ s.interface }}</td>
|
||||
<td class="mono">{{ s.range_start }} – {{ s.range_end }}</td>
|
||||
<td class="mono text-faint">{{ s.gateway or '(automatisch)' }}</td>
|
||||
<td class="mono text-faint">{{ s.dns or '—' }}</td>
|
||||
<td>
|
||||
{% if not s.enabled %}
|
||||
<span class="pill disabled">Deaktiviert</span>
|
||||
{% elif st and st.ok %}
|
||||
<span class="pill online">Erkannt</span>
|
||||
<span class="mono text-faint" style="font-size:11.5px;">{{ st.network }}</span>
|
||||
{% else %}
|
||||
<span class="pill offline">Nicht erkannt</span>
|
||||
{% if st %}<span class="text-faint" style="font-size:11px;">{{ st.error }}</span>{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if can_edit %}
|
||||
<div class="flex gap-2">
|
||||
<form method="post">
|
||||
<input type="hidden" name="toggle_dhcp_subnet" value="{{ s.id }}">
|
||||
<button type="submit" class="icon-btn" title="{{ 'Deaktivieren' if s.enabled else 'Aktivieren' }}">
|
||||
{% if s.enabled %}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M4.9 4.9l14.2 14.2"/></svg>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<button type="button" class="icon-btn" title="Bearbeiten" data-open-modal="editSubnetModal{{ loop.index }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4z"/></svg>
|
||||
</button>
|
||||
<form method="post" data-confirm="Subnetz {{ s.range_start }}–{{ s.range_end }} löschen?">
|
||||
<input type="hidden" name="delete_dhcp_subnet" value="{{ s.id }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Noch kein Subnetz konfiguriert — der Dienst kann so nicht gestartet werden.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">DHCP-Options</h2>
|
||||
<div class="hint">
|
||||
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
|
||||
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
|
||||
</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neue Option
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% set visible_option_defs = option_defs|rejectattr("is_standard")|list + option_defs|selectattr("is_standard")|selectattr("id", "in", option_values.keys()|list)|list %}
|
||||
{% if visible_option_defs %}
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Code</th><th>Name</th><th>Typ</th><th>Herkunft</th><th>Beschreibung</th><th>Globaler Wert</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in visible_option_defs|sort(attribute="code") %}
|
||||
<tr>
|
||||
<td class="mono">{{ d.code }}</td>
|
||||
<td class="mono">{{ d.name }}</td>
|
||||
<td>{{ d.type }}</td>
|
||||
<td>
|
||||
{% if d.is_standard %}
|
||||
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);">Standard</span>
|
||||
{% else %}
|
||||
<span class="pill user">Eigen</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-faint" style="font-size:12px;">{{ d.description or '—' }}</td>
|
||||
<td>
|
||||
{% if can_edit %}
|
||||
<form method="post" class="flex gap-2">
|
||||
<input type="hidden" name="save_dhcp_option_global" value="1">
|
||||
<input type="hidden" name="option_def_id" value="{{ d.id }}">
|
||||
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
|
||||
</form>
|
||||
<div class="field-hint">Leeren Wert speichern, um eine Standard-Option wieder auszublenden.</div>
|
||||
{% else %}
|
||||
{{ option_values.get(d.id, {}).get('', '—') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if can_edit and not d.is_standard %}
|
||||
<form method="post" data-confirm="Option „{{ d.name }}“ inkl. aller Werte/Overrides löschen?">
|
||||
<input type="hidden" name="delete_dhcp_option" value="{{ d.id }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% elif d.is_standard %}
|
||||
<span class="text-faint" title="Standard-Optionen können nicht gelöscht werden">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Noch keine Options in Verwendung.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
{% set unused_standard_defs = option_defs|selectattr("is_standard")|rejectattr("id", "in", option_values.keys()|list)|sort(attribute="code")|list %}
|
||||
{% if unused_standard_defs %}
|
||||
<form method="post" class="flex gap-2" style="margin-top:14px; align-items:flex-end; flex-wrap:wrap;">
|
||||
<input type="hidden" name="save_dhcp_option_global" value="1">
|
||||
<div class="field" style="margin:0; min-width:260px;">
|
||||
<label>Standard-Option hinzufügen</label>
|
||||
<select name="option_def_id">
|
||||
{% for d in unused_standard_defs %}
|
||||
<option value="{{ d.id }}">{{ d.name }} (Code {{ d.code }}) — {{ d.description }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field" style="margin:0;">
|
||||
<label>Wert</label>
|
||||
<input type="text" name="value" placeholder="Wert eintragen…" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary">Hinzufügen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Aktive Leases</h2>
|
||||
<div class="hint">
|
||||
Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung,
|
||||
die sich einfach eine freie IP aus dem Pool genommen haben.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if leases is none %}
|
||||
<p class="text-faint" style="font-size:12.5px;">
|
||||
Keine Lease-Datei gefunden — der Dienst wurde vermutlich noch nie gestartet oder hat noch keine Adresse vergeben.
|
||||
</p>
|
||||
{% elif leases %}
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>IP-Adresse</th><th>MAC</th><th>Hostname</th><th>Läuft ab</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for l in leases %}
|
||||
<tr>
|
||||
<td class="mono">{{ l.ip }}</td>
|
||||
<td class="mono">{{ l.mac }}</td>
|
||||
<td>{{ l.hostname or '—' }}</td>
|
||||
<td class="text-faint mono" style="font-size:12px;">
|
||||
{% if l.expires_at %}
|
||||
{{ l.expires_at }} UTC
|
||||
<br><span class="lease-countdown" data-expire-ms="{{ l.expire_epoch_ms }}">…</span>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if l.reserved %}
|
||||
<span class="pill online">Reserviert</span>
|
||||
{% else %}
|
||||
<span class="pill unknown" title="Client hat sich diese IP selbst aus dem Pool geholt, ohne eigene Reservierung">
|
||||
⚠ Ohne Reservierung
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Aktuell keine aktiven Leases vergeben.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card card-pad" style="grid-column:1 / -1;">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Reservierungen aus den Clients</h2>
|
||||
<div class="hint">
|
||||
{{ reservations|length }} aktive{{ ' Clients' if reservations|length != 1 else 'r Client' }} mit gültiger MAC + IP in einem der konfigurierten Subnetze.
|
||||
{% if skipped_count %}{{ skipped_count }} aktive{{ ' Clients' if skipped_count != 1 else 'r Client' }} ohne MAC/IP übersprungen.{% endif %}
|
||||
{% if out_of_subnet_count %}{{ out_of_subnet_count }} aktive{{ ' Clients außerhalb' if out_of_subnet_count != 1 else 'r Client außerhalb' }} aller konfigurierten Subnetze übersprungen.{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-secondary" data-open-modal="addReservationModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Reservierung hinzufügen
|
||||
</button>
|
||||
<form method="post" data-confirm="Konfiguration nach „{{ cfg.dhcp_output_path }}“ schreiben? Der Dienst übernimmt die Änderung erst nach einem Neustart.">
|
||||
<input type="hidden" name="write_dhcp_file" value="1">
|
||||
<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="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if reservations %}
|
||||
<div class="table-wrap" style="margin-bottom:18px;">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Hostname</th><th>MAC</th><th>IP-Adresse</th><th>Name</th><th>Quelle</th><th>Subnetz</th><th>Optionen</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in reservations %}
|
||||
<tr>
|
||||
<td class="mono">{{ r.hostname }}</td>
|
||||
<td class="mono">{{ r.mac }}</td>
|
||||
<td class="mono">{{ r.ip }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>
|
||||
{% if r.source == 'manual' %}
|
||||
<span class="pill user">Manuell</span>
|
||||
{% else %}
|
||||
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);">Auto (Client)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono text-faint" style="font-size:12px;">{{ r.subnet_label }}</td>
|
||||
<td style="font-size:12px;">
|
||||
{% for d in option_defs %}
|
||||
{% set override = option_values.get(d.id, {}).get(r.mac) %}
|
||||
{% set global_val = option_values.get(d.id, {}).get('') %}
|
||||
{% if override %}
|
||||
<span class="pill user" title="{{ d.name }} = {{ override }}">{{ d.name }} (eigen)</span>
|
||||
{% elif global_val %}
|
||||
<span class="pill" style="background:var(--muted-dim); color:var(--text-faint);" title="{{ d.name }} = {{ global_val }}">{{ d.name }} (global)</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
{% if option_defs and can_edit %}
|
||||
<button type="button" class="icon-btn" title="DHCP-Optionen für diesen Client" data-open-modal="deviceOptionsModal{{ loop.index }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if can_edit and r.source == 'manual' %}
|
||||
<form method="post" data-confirm="Manuelle Reservierung für {{ r.mac }} löschen?">
|
||||
<input type="hidden" name="delete_dhcp_reservation" value="{{ r.manual_id }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% elif r.source == 'auto' %}
|
||||
<span class="text-faint" title="Automatische Reservierungen entstehen aus den Client-Stammdaten und können hier nicht gelöscht werden">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modals außerhalb der Tabelle: ein <div> direkt in <tbody> ist
|
||||
ungültiges HTML und wird vom Browser aus der Tabelle heraus
|
||||
"foster-parented" — dabei kann die Eltern-Kind-Beziehung zwischen
|
||||
Formular und Feldern zerrissen werden. -->
|
||||
{% if option_defs and can_edit %}
|
||||
{% for r in reservations %}
|
||||
<div class="modal-overlay" id="deviceOptionsModal{{ loop.index }}">
|
||||
<div class="modal">
|
||||
<form method="post">
|
||||
<input type="hidden" name="save_dhcp_device_options" value="1">
|
||||
<input type="hidden" name="mac" value="{{ r.mac }}">
|
||||
<div class="modal-header">
|
||||
<h3>DHCP-Optionen — {{ r.name }}</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">
|
||||
Nur Options mit gesetztem Override sind sichtbar — über das Dropdown weitere hinzufügen.
|
||||
Leerer Wert übernimmt den globalen Wert (bzw. entfernt den Override).
|
||||
</p>
|
||||
<select onchange="poeShowOptionField(this)">
|
||||
<option value="">+ Option hinzufügen…</option>
|
||||
{% for d in option_defs %}
|
||||
{% if not option_values.get(d.id, {}).get(r.mac) %}
|
||||
<option value="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}">{{ d.name }} (Code {{ d.code }})</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<!-- Neu ausgewählte Felder landen hier direkt unter dem Dropdown
|
||||
(siehe poeShowOptionField in app.js), statt irgendwo in der
|
||||
u.U. langen Liste unten sichtbar zu werden. Bereits gesetzte
|
||||
Overrides stehen von Anfang an hier drin. -->
|
||||
<div class="poe-added-options" style="margin:10px 0;">
|
||||
{% for d in option_defs %}
|
||||
{% set current = option_values.get(d.id, {}).get(r.mac, '') %}
|
||||
{% if current %}
|
||||
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}">
|
||||
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
|
||||
<input type="text" name="opt_{{ d.id }}" value="{{ current }}"
|
||||
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for d in option_defs %}
|
||||
{% set current = option_values.get(d.id, {}).get(r.mac, '') %}
|
||||
{% if not current %}
|
||||
<div class="field" id="opt-field-{{ d.id }}-{{ loop.index }}-{{ r.mac }}" style="display:none;">
|
||||
<label>{{ d.name }} <span class="text-faint">(Code {{ d.code }})</span></label>
|
||||
<input type="text" name="opt_{{ d.id }}" value=""
|
||||
placeholder="{{ option_values.get(d.id, {}).get('', '(kein globaler Wert)') }}">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Keine aktiven Clients mit MAC + IP vorhanden.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="permission-group-title">Generierte Konfiguration (Vorschau, Kea-JSON)</div>
|
||||
<pre class="code-preview">{{ preview }}</pre>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="modal-overlay" id="addOptionModal">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
<form method="post">
|
||||
<input type="hidden" name="add_dhcp_option" value="1">
|
||||
<div class="modal-header">
|
||||
<h3>Neue DHCP-Option</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Code</label>
|
||||
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
|
||||
</div>
|
||||
<div class="field"><label>Name</label>
|
||||
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
|
||||
<div class="field-hint">Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.</div>
|
||||
</div>
|
||||
<div class="field"><label>Datentyp</label>
|
||||
<select name="type">
|
||||
{% for key, label in option_types %}
|
||||
<option value="{{ key }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Beschreibung</label>
|
||||
<input type="text" name="description" placeholder="z.B. Terminal-Boot-URL für Thin Clients">
|
||||
</div>
|
||||
<div class="field"><label>Globaler Wert (optional)</label>
|
||||
<input type="text" name="initial_value" placeholder="z.B. http://bde/webmmi?M=SGM11">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="addReservationModal">
|
||||
<div class="modal">
|
||||
<form method="post">
|
||||
<input type="hidden" name="add_dhcp_reservation" value="1">
|
||||
<div class="modal-header">
|
||||
<h3>Reservierung hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-faint" style="font-size:11.5px; margin:0 0 14px;">
|
||||
Für Geräte, die nicht als Client in dieser App gepflegt werden (z.B. ein externes Gerät) — bewusst
|
||||
eine eigene, manuelle Aktion, damit keine unüberwachten Clients automatisch eine feste IP bekommen.
|
||||
</p>
|
||||
<div class="field"><label>MAC-Adresse</label>
|
||||
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.50">
|
||||
<div class="field-hint">Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.</div>
|
||||
</div>
|
||||
<div class="field"><label>Name</label>
|
||||
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
|
||||
<div class="field-hint">Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="addSubnetModal">
|
||||
<div class="modal">
|
||||
<form method="post">
|
||||
<input type="hidden" name="add_dhcp_subnet" value="1">
|
||||
<div class="modal-header">
|
||||
<h3>Subnetz hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Interface</label>
|
||||
<select name="interface" required>
|
||||
{% for iface in interfaces %}
|
||||
<option value="{{ iface }}">{{ iface }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Range Start</label>
|
||||
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
|
||||
</div>
|
||||
<div class="field"><label>Range Ende</label>
|
||||
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
|
||||
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
|
||||
</div>
|
||||
<div class="field"><label>Gateway (optional)</label>
|
||||
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
||||
</div>
|
||||
<div class="field"><label>DNS-Server (optional)</label>
|
||||
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bearbeiten-Modals außerhalb der Karte/Tabelle (siehe Kommentar oben
|
||||
bei den Reservierungen — dieselbe HTML-Validität-Begründung). -->
|
||||
{% if can_edit %}
|
||||
{% for s in subnets %}
|
||||
<div class="modal-overlay" id="editSubnetModal{{ loop.index }}">
|
||||
<div class="modal">
|
||||
<form method="post">
|
||||
<input type="hidden" name="edit_dhcp_subnet" value="{{ s.id }}">
|
||||
<div class="modal-header">
|
||||
<h3>Subnetz bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Interface</label>
|
||||
<select name="interface" required>
|
||||
{% for iface in interfaces %}
|
||||
<option value="{{ iface }}" {% if iface == s.interface %}selected{% endif %}>{{ iface }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field"><label>Range Start</label>
|
||||
<input type="text" name="range_start" value="{{ s.range_start }}" required>
|
||||
</div>
|
||||
<div class="field"><label>Range Ende</label>
|
||||
<input type="text" name="range_end" value="{{ s.range_end }}" required>
|
||||
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
|
||||
</div>
|
||||
<div class="field"><label>Gateway (optional)</label>
|
||||
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
|
||||
</div>
|
||||
<div class="field"><label>DNS-Server (optional)</label>
|
||||
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
|
||||
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "settings_importexport" %}
|
||||
{% block page_title %}Im-/Export{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-grid">
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Import</h2>
|
||||
<div class="hint">
|
||||
{% if import_preview %}
|
||||
Datei erfolgreich gelesen — wähle aus, welche Kategorien tatsächlich eingespielt werden sollen.
|
||||
{% else %}
|
||||
Exportiertes Bundle einlesen — nach dem Entschlüsseln wählst du aus, was übernommen wird.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not current_user.has_permission('settings_importexport.edit') %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Für den Import fehlt das Recht „Im-/Export ändern“.</p>
|
||||
{% elif import_preview %}
|
||||
<form method="post" action="{{ url_for('import_apply') }}"
|
||||
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
|
||||
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
|
||||
<div class="field">
|
||||
<label>Was importieren?</label>
|
||||
<div class="check-list">
|
||||
{% for s in import_preview.sections %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="import_sections" value="{{ s.key }}"
|
||||
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
||||
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
|
||||
{{ s.label }} <span class="text-faint">({{ s.count }})</span>
|
||||
{% if s.admin_only %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="field-hint">
|
||||
AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet,
|
||||
nicht über diese App, und beim nächsten Login automatisch neu angelegt.
|
||||
</div>
|
||||
</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>
|
||||
Ausgewählte Kategorien importieren
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('settings_import_export') }}" class="text-faint" style="font-size:12px; display:inline-block; margin-top:10px;">Abbrechen / andere Datei wählen</a>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data">
|
||||
<div class="field">
|
||||
<label for="import_file">Export-Datei</label>
|
||||
<input type="file" name="import_file" id="import_file" accept=".json" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="import_passphrase">Passphrase</label>
|
||||
<input type="password" name="import_passphrase" id="import_passphrase" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 9V5a2 2 0 00-2-2H5a2 2 0 00-2 2v4"/><path d="M7 14l5-5 5 5"/><path d="M12 9v12"/></svg>
|
||||
Datei lesen & Vorschau anzeigen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Export</h2>
|
||||
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has_permission('settings_importexport.export') %}
|
||||
<form method="post" action="{{ url_for('export_data') }}">
|
||||
<div class="field">
|
||||
<label>Was exportieren?</label>
|
||||
<div class="check-list">
|
||||
{% for key, label in export_sections %}
|
||||
<label class="check-row">
|
||||
<input type="checkbox" name="export_sections" value="{{ key }}"
|
||||
{% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
|
||||
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
|
||||
{{ label }}
|
||||
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="field-hint">
|
||||
AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet
|
||||
(nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird
|
||||
lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="export_passphrase">Passphrase</label>
|
||||
<input type="password" name="export_passphrase" id="export_passphrase" required>
|
||||
<div class="field-hint">Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary btn-block">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
|
||||
Export herunterladen
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "settings_ldap" %}
|
||||
{% set can_edit = current_user.has_permission('settings_ldap.edit') %}
|
||||
{% block page_title %}LDAP / Active Directory{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="settings-grid" style="grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));">
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Verbindung</h2>
|
||||
<div class="hint">Server, Bind-Konto und Suchparameter für die Anbindung an AD/LDAP.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||
<input type="checkbox" name="ldap_enabled" {% if ldap.enabled %}checked{% endif %}>
|
||||
<span class="track"></span>
|
||||
<span>LDAP-Anmeldung aktivieren</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field"><label>Server</label>
|
||||
<input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local">
|
||||
<div class="field-hint">DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||
<input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %}
|
||||
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
|
||||
<span class="track"></span>
|
||||
<span>LDAPS/TLS verwenden</span>
|
||||
</label>
|
||||
<div class="field-hint">Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.</div>
|
||||
</div>
|
||||
<div class="field"><label>Port</label>
|
||||
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
|
||||
<input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}>
|
||||
<span class="track"></span>
|
||||
<span>Zertifikatsprüfung überspringen (nur LDAPS)</span>
|
||||
</label>
|
||||
<div class="field-hint">Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.</div>
|
||||
</div>
|
||||
<div class="field"><label>Bind-Konto (Service-Account)</label>
|
||||
<input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local">
|
||||
<div class="field-hint">Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.</div>
|
||||
</div>
|
||||
<div class="field"><label>Bind-Passwort</label>
|
||||
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
|
||||
</div>
|
||||
<div class="field"><label>Base-DN</label>
|
||||
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
|
||||
</div>
|
||||
<div class="field"><label>Attribut für Benutzername</label>
|
||||
<input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName">
|
||||
<div class="field-hint">Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.</div>
|
||||
</div>
|
||||
<div class="field"><label>Standardgruppe für neue AD-Benutzer</label>
|
||||
<select name="ldap_default_group">
|
||||
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
|
||||
{% for g in ldap_groups %}
|
||||
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
|
||||
</div>
|
||||
<div class="flex gap-2" style="flex-wrap:wrap;">
|
||||
<button type="submit" name="save_ldap" value="1" 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>
|
||||
Speichern
|
||||
</button>
|
||||
<button type="submit" name="test_ldap" value="1" class="btn btn-secondary" formnovalidate>
|
||||
Verbindung testen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if ldap.bind_dn %}
|
||||
<form method="post" data-confirm="LDAP-Bind-Konto wirklich löschen? Die LDAP-Anmeldung wird dabei deaktiviert." style="margin-top:10px;">
|
||||
<input type="hidden" name="clear_ldap_bind" value="1">
|
||||
<button type="submit" class="btn btn-sm" style="color:var(--danger); background:transparent; border-color:var(--danger-dim);">
|
||||
Bind-Konto löschen
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="section-head" style="margin-bottom:16px;">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">AD-Gruppenzuordnungen</h2>
|
||||
<div class="hint">
|
||||
Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten
|
||||
AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere
|
||||
Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.
|
||||
</div>
|
||||
</div>
|
||||
{% if can_edit %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Zuordnung hinzufügen
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if mappings %}
|
||||
<div class="table-wrap">
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>AD-Gruppe</th><th>App-Rechtegruppe</th><th style="width:1%;">Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in mappings %}
|
||||
<tr>
|
||||
<td>{{ m.ad_group_name }}<div class="text-faint mono" style="font-size:11px;">{{ m.ad_group_dn }}</div></td>
|
||||
<td>{{ m.app_group_name or '—' }}</td>
|
||||
<td>
|
||||
{% if can_edit %}
|
||||
<form method="post" data-confirm="Zuordnung „{{ m.ad_group_name }} → {{ m.app_group_name }}“ löschen?">
|
||||
<input type="hidden" name="delete_ldap_group_mapping" value="{{ m.id }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Noch keine Zuordnung angelegt — alle neuen AD-Benutzer erhalten nur die Standardgruppe.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{% if can_edit %}
|
||||
<div class="modal-overlay" id="addLdapMappingModal">
|
||||
<div class="modal">
|
||||
<form method="post">
|
||||
<input type="hidden" name="add_ldap_group_mapping" value="1">
|
||||
<div class="modal-header">
|
||||
<h3>AD-Gruppenzuordnung hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>AD-Gruppe</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="ad_group_dn" id="ldapMappingAdGroup" required style="flex:1;">
|
||||
<option value="">— zuerst laden —</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm" id="ldapMappingLoadGroupsBtn">Gruppen laden</button>
|
||||
</div>
|
||||
<input type="hidden" name="ad_group_name" id="ldapMappingAdGroupName">
|
||||
<div class="field-hint" id="ldapMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
|
||||
</div>
|
||||
<div class="field"><label>App-Rechtegruppe</label>
|
||||
<select name="app_group_id" required>
|
||||
<option value="">— auswählen —</option>
|
||||
<option value="admin">Admin (alle Rechte)</option>
|
||||
{% for g in ldap_groups %}
|
||||
<option value="{{ g['id'] }}">{{ g['name'] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var btn = document.getElementById('ldapMappingLoadGroupsBtn');
|
||||
var select = document.getElementById('ldapMappingAdGroup');
|
||||
var nameField = document.getElementById('ldapMappingAdGroupName');
|
||||
var status = document.getElementById('ldapMappingLoadStatus');
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
status.textContent = 'Lade Gruppen …';
|
||||
fetch("{{ url_for('settings_ldap_ad_groups') }}")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (groups) {
|
||||
if (!Array.isArray(groups)) {
|
||||
status.textContent = groups.error || 'Fehler beim Laden.';
|
||||
return;
|
||||
}
|
||||
select.innerHTML = '';
|
||||
if (!groups.length) {
|
||||
select.innerHTML = '<option value="">Keine Gruppen gefunden</option>';
|
||||
status.textContent = 'Keine Gruppen gefunden — Verbindung/Bind-Konto prüfen.';
|
||||
return;
|
||||
}
|
||||
groups.forEach(function (g) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = g.dn;
|
||||
opt.textContent = g.name;
|
||||
opt.dataset.name = g.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
nameField.value = select.options[select.selectedIndex].dataset.name || '';
|
||||
status.textContent = groups.length + ' Gruppe(n) geladen.';
|
||||
})
|
||||
.catch(function () { status.textContent = 'Fehler beim Laden — Verbindung/Bind-Konto prüfen.'; });
|
||||
});
|
||||
|
||||
select.addEventListener('change', function () {
|
||||
var opt = select.options[select.selectedIndex];
|
||||
nameField.value = (opt && opt.dataset.name) || '';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,434 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "switches" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/xterm.css') }}">
|
||||
{% endblock %}
|
||||
{% set can_create = current_user.has_permission('switches.create') %}
|
||||
{% set can_edit = current_user.has_permission('switches.edit') %}
|
||||
{% set can_delete = current_user.has_permission('switches.edit') %}
|
||||
{% block page_title %}Switche{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Switche</h2>
|
||||
<div class="hint">Aruba-Switche mit ihren Zugangsdaten für PoE-Neustart und Verbindungstest.</div>
|
||||
</div>
|
||||
{% if can_create %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="addSwitchModal" onclick="resetCredentialChoice('add')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neuer Switch
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="switchSearch" placeholder="Switches durchsuchen…" oninput="filterTable('switchSearch','switchesTable')">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="switchesTable" data-sortable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort-key="hostname">Hostname</th>
|
||||
<th data-sort-key="ip">IP-Adresse</th>
|
||||
<th data-sort-key="ssh_port">SSH-Port</th>
|
||||
<th data-sort-key="credential">Zugangsdaten</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in switches %}
|
||||
<tr data-sort-hostname="{{ s['hostname']|lower }}" data-sort-ip="{{ s['ip']|lower }}" data-sort-ssh_port="{{ s['ssh_port'] or 22 }}" data-sort-credential="{{ (s['credential_name'] or '')|lower }}">
|
||||
<td class="cell-name">{{ s['hostname'] }}</td>
|
||||
<td class="mono">{{ s['ip'] }}</td>
|
||||
<td class="mono">
|
||||
{{ s['ssh_port'] or 22 }}
|
||||
{% if not s['ssh_port'] %}<span class="text-faint" style="font-size:11px;">(Standard)</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if s['credential_name'] %}
|
||||
{{ s['credential_name'] }} <span class="text-faint mono" style="font-size:11.5px;">({{ s['credential_username'] }})</span>
|
||||
{% else %}
|
||||
<span class="text-faint">— keine —</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if can_edit %}
|
||||
<button class="icon-btn" title="Bearbeiten" data-open-modal="editSwitchModal{{ loop.index }}" onclick="resetCredentialChoice('edit{{ loop.index }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if can_delete %}
|
||||
<form method="post" action="{{ url_for('delete_switch', hostname=s['hostname']) }}" data-confirm="Willst du den Switch „{{ s['hostname'] }}“ wirklich löschen?">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="5">Noch keine Switche vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bearbeiten-Modals außerhalb der Tabelle: ein <div> direkt in <tbody>
|
||||
ist ungültiges HTML — Browser "foster-parenten" es dann aus der
|
||||
Tabelle heraus und zerreißen dabei mitunter die Eltern-Kind-Beziehung
|
||||
zwischen Formular und Buttons (this.closest('form') lieferte dadurch
|
||||
null, "Verbindung testen" öffnete kein Terminal mehr). -->
|
||||
{% if can_edit %}
|
||||
{% for s in switches %}
|
||||
<div class="modal-overlay" id="editSwitchModal{{ loop.index }}">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
<form method="post" onsubmit="return validateSwitchForm(this, 'edit{{ loop.index }}');">
|
||||
<input type="hidden" name="edit_switch" value="1">
|
||||
<input type="hidden" name="old_hostname" value="{{ s['hostname'] }}">
|
||||
<div class="modal-header">
|
||||
<h3>Switch bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="hostname" value="{{ s['hostname'] }}" required>
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>SSH-Port</label>
|
||||
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
|
||||
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zugangsdaten</label>
|
||||
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
|
||||
{% for c in all_credentials %}
|
||||
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}" {% if c['id'] == s['credential_id'] %}selected{% endif %}>{{ c['name'] }}</option>
|
||||
{% endfor %}
|
||||
<option value="new">+ Neue Zugangsdaten anlegen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="new-credential-fields hidden">
|
||||
<div class="field"><label>Name der Zugangsdaten</label>
|
||||
<input type="text" name="new_credential_name" placeholder="z.B. Lager-Switche">
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="new_credential_username" placeholder="z.B. admin">
|
||||
</div>
|
||||
<div class="field"><label>Passwort</label>
|
||||
<input type="password" id="password_edit{{ loop.index }}" name="new_credential_password">
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_edit{{ loop.index }}" name="new_credential_password_confirm">
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||
Verbindung testen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if can_create %}
|
||||
<!-- Modal: Neuer Switch -->
|
||||
<div class="modal-overlay" id="addSwitchModal">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
<form method="post" onsubmit="return validateSwitchForm(this, 'add');">
|
||||
<div class="modal-header">
|
||||
<h3>Neuen Switch hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="add_switch" value="1">
|
||||
<div class="field"><label>Hostname</label>
|
||||
<input type="text" name="hostname" required placeholder="z.B. Switch01">
|
||||
</div>
|
||||
<div class="field"><label>IP-Adresse</label>
|
||||
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
|
||||
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
|
||||
</div>
|
||||
<div class="field"><label>SSH-Port</label>
|
||||
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
|
||||
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zugangsdaten</label>
|
||||
<select name="credential_choice" class="credential-select" onchange="toggleNewCredentialFields(this)">
|
||||
{% if not all_credentials %}<option value="new" selected>+ Neue Zugangsdaten anlegen</option>
|
||||
{% else %}
|
||||
{% for c in all_credentials %}
|
||||
<option value="{{ c['id'] }}" data-username="{{ c['username'] }}">{{ c['name'] }}</option>
|
||||
{% endfor %}
|
||||
<option value="new">+ Neue Zugangsdaten anlegen</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="new-credential-fields {% if all_credentials %}hidden{% endif %}">
|
||||
<div class="field"><label>Name der Zugangsdaten</label>
|
||||
<input type="text" name="new_credential_name" placeholder="z.B. Lager-Switche">
|
||||
</div>
|
||||
<div class="field"><label>Username</label>
|
||||
<input type="text" name="new_credential_username" placeholder="z.B. admin">
|
||||
</div>
|
||||
<div class="field"><label>Passwort</label>
|
||||
<input type="password" id="password_add" name="new_credential_password">
|
||||
</div>
|
||||
<div class="field"><label>Passwort bestätigen</label>
|
||||
<input type="password" id="password_confirm_add" name="new_credential_password_confirm">
|
||||
<div class="invalid-feedback">Passwörter stimmen nicht überein!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" style="margin-right:auto;" onclick="openTerminal(this.closest('form'))">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/></svg>
|
||||
Verbindung testen
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if can_create or can_edit %}
|
||||
<!-- Modal: SSH-Verbindungstest (echtes Terminal, zum Akzeptieren von Host-Keys
|
||||
und Prüfen der Zugangsdaten, bevor der Switch gespeichert wird) -->
|
||||
<div class="modal-overlay" id="terminalModal">
|
||||
<div class="modal" style="max-width:720px;">
|
||||
<div class="modal-header">
|
||||
<h3>SSH-Verbindungstest</h3>
|
||||
<button type="button" class="modal-close" data-close-modal onclick="closeTerminal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding:0;">
|
||||
<div class="term-toolbar">
|
||||
<div class="flex gap-2" style="align-items:center;">
|
||||
<span id="termStatus" class="pill unknown">Bereit</span>
|
||||
<span id="termTarget" class="text-faint mono" style="font-size:12px;"></span>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="termPastePassword">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
Passwort einfügen
|
||||
</button>
|
||||
</div>
|
||||
<div id="terminal" class="xterm-container"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<p class="text-faint" style="font-size:11.5px; margin-right:auto;">
|
||||
Erste Verbindung? Bestätige den Host-Key mit „yes“, gib danach das Passwort ein — direkt hier im Terminal.
|
||||
Bei bestehenden Zugangsdaten ist das Passwort hier nicht bekannt (verschlüsselt gespeichert) — bitte manuell eingeben.
|
||||
</p>
|
||||
<button type="button" class="btn btn-secondary" data-close-modal onclick="closeTerminal()">Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/vendor/xterm.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/vendor/xterm-addon-fit.js') }}"></script>
|
||||
<script>
|
||||
// -------------------------------------------------------------------------
|
||||
// Zugangsdaten-Auswahl: "+ Neue Zugangsdaten anlegen" blendet die Felder ein
|
||||
// -------------------------------------------------------------------------
|
||||
function toggleNewCredentialFields(select) {
|
||||
const fields = select.closest(".modal-body").querySelector(".new-credential-fields");
|
||||
if (fields) fields.classList.toggle("hidden", select.value !== "new");
|
||||
}
|
||||
function resetCredentialChoice(id) {
|
||||
// Beim Öffnen sicherstellen, dass die "Neue Zugangsdaten"-Felder passend
|
||||
// zur aktuellen Auswahl ein-/ausgeblendet sind (relevant v.a. nach
|
||||
// vorherigem Umschalten auf "neu" ohne zu speichern).
|
||||
setTimeout(() => {
|
||||
const select = document.querySelector(`#${id === 'add' ? 'addSwitchModal' : 'editSwitchModal' + id.replace('edit','')} .credential-select`);
|
||||
if (select) toggleNewCredentialFields(select);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// SSH-Verbindungstest (Web-Terminal via /ws/ssh_terminal)
|
||||
// -------------------------------------------------------------------------
|
||||
let term = null, fitAddon = null, termSocket = null, activePasswordInput = null;
|
||||
|
||||
function ensureTerminal() {
|
||||
if (term) return;
|
||||
term = new Terminal({
|
||||
convertEol: true,
|
||||
fontSize: 13,
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Consolas, monospace",
|
||||
cursorBlink: true,
|
||||
theme: { background: "#0a0c10", foreground: "#c7ccd6" },
|
||||
});
|
||||
fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById("terminal"));
|
||||
fitAddon.fit();
|
||||
term.onData((data) => {
|
||||
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
|
||||
termSocket.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (termSocket && termSocket.readyState === WebSocket.OPEN) {
|
||||
termSocket.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
});
|
||||
window.addEventListener("resize", () => fitAddon && fitAddon.fit());
|
||||
}
|
||||
|
||||
function setTermStatus(text, cls) {
|
||||
const el = document.getElementById("termStatus");
|
||||
el.className = "pill " + cls;
|
||||
el.innerText = text;
|
||||
}
|
||||
|
||||
function getCredentialInfo(form) {
|
||||
const select = form.querySelector(".credential-select");
|
||||
if (!select || select.value === "new") {
|
||||
return {
|
||||
username: (form.querySelector("input[name='new_credential_username']") || {}).value?.trim(),
|
||||
passwordInput: form.querySelector("input[name='new_credential_password']"),
|
||||
};
|
||||
}
|
||||
const option = select.selectedOptions[0];
|
||||
return { username: option ? option.dataset.username : null, passwordInput: null };
|
||||
}
|
||||
|
||||
function openTerminal(form) {
|
||||
const host = (form.querySelector("input[name='ip']") || {}).value?.trim();
|
||||
const port = parseInt((form.querySelector("input[name='ssh_port']") || {}).value, 10) || 22;
|
||||
const { username, passwordInput } = getCredentialInfo(form);
|
||||
activePasswordInput = passwordInput;
|
||||
|
||||
if (!host || !username) {
|
||||
showToast("Bitte IP-Adresse ausfüllen und Zugangsdaten auswählen/anlegen, bevor du die Verbindung testest.", "danger");
|
||||
return;
|
||||
}
|
||||
|
||||
PoeUI.openModal("terminalModal");
|
||||
ensureTerminal();
|
||||
term.reset();
|
||||
document.getElementById("termTarget").innerText = `${username}@${host}:${port}`;
|
||||
setTermStatus("Verbinde…", "unknown");
|
||||
setTimeout(() => fitAddon && fitAddon.fit(), 60);
|
||||
|
||||
if (termSocket) { try { termSocket.close(); } catch (e) {} }
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
termSocket = new WebSocket(`${proto}//${location.host}/ws/ssh_terminal`);
|
||||
|
||||
termSocket.onopen = () => {
|
||||
termSocket.send(JSON.stringify({ host, username, port }));
|
||||
setTermStatus("Verbunden", "online");
|
||||
};
|
||||
termSocket.onmessage = (event) => term.write(event.data);
|
||||
termSocket.onclose = () => setTermStatus("Getrennt", "offline");
|
||||
// Bewusst kein Toast bei "error": manche Browser melden beim Schließen
|
||||
// einer WebSocket-Verbindung ein error-Event, obwohl die Sitzung
|
||||
// inhaltlich erfolgreich war. Terminal-Inhalt + Status-Pill genügen als
|
||||
// Feedback — der Verbindungsstatus wird zuverlässig über onclose gepflegt.
|
||||
termSocket.onerror = () => setTermStatus("Fehler", "offline");
|
||||
}
|
||||
|
||||
function closeTerminal() {
|
||||
if (termSocket) {
|
||||
try { termSocket.close(); } catch (e) {}
|
||||
termSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
const termPasteBtn = document.getElementById("termPastePassword");
|
||||
if (termPasteBtn) {
|
||||
termPasteBtn.addEventListener("click", () => {
|
||||
if (!activePasswordInput || !activePasswordInput.value) {
|
||||
showToast("Kein Passwort bekannt — bitte manuell im Terminal eingeben.", "danger");
|
||||
return;
|
||||
}
|
||||
if (!termSocket || termSocket.readyState !== WebSocket.OPEN) {
|
||||
showToast("Keine aktive Terminal-Verbindung.", "danger");
|
||||
return;
|
||||
}
|
||||
termSocket.send(JSON.stringify({ type: "input", data: activePasswordInput.value + "\n" }));
|
||||
});
|
||||
}
|
||||
|
||||
// Verbindung auch beim Schließen per Klick auf Backdrop / Escape sauber trennen
|
||||
const terminalModalEl = document.getElementById("terminalModal");
|
||||
if (terminalModalEl) {
|
||||
terminalModalEl.addEventListener("click", (e) => {
|
||||
if (e.target.id === "terminalModal") closeTerminal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && terminalModalEl.classList.contains("open")) closeTerminal();
|
||||
});
|
||||
}
|
||||
|
||||
const ipPattern = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
||||
|
||||
function validateIP(input) {
|
||||
const ok = ipPattern.test(input.value);
|
||||
input.classList.toggle("is-invalid", !ok);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function validateNewCredentialPassword(form) {
|
||||
const select = form.querySelector(".credential-select");
|
||||
if (!select || select.value !== "new") return true;
|
||||
const pass = form.querySelector("input[name='new_credential_password']");
|
||||
const confirm = form.querySelector("input[name='new_credential_password_confirm']");
|
||||
if (!pass || !confirm) return true;
|
||||
if (pass.value !== confirm.value) { confirm.classList.add("is-invalid"); return false; }
|
||||
confirm.classList.remove("is-invalid");
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateSwitchForm(form, id) {
|
||||
const ipInput = form.querySelector("input[name='ip']");
|
||||
let valid = true;
|
||||
if (ipInput) valid = validateIP(ipInput) && valid;
|
||||
valid = validateNewCredentialPassword(form) && valid;
|
||||
return valid;
|
||||
}
|
||||
|
||||
document.addEventListener("input", (e) => {
|
||||
if (e.target.name === "ip") validateIP(e.target);
|
||||
if (e.target.name === "new_credential_password_confirm") {
|
||||
const form = e.target.closest("form");
|
||||
const pass = form.querySelector("input[name='new_credential_password']");
|
||||
if (pass) e.target.classList.toggle("is-invalid", pass.value !== e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
function filterTable(inputId, tableId) {
|
||||
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,321 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "users" %}
|
||||
{% block page_title %}Benutzer{% endblock %}
|
||||
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2 style="font-size:16px;">Benutzer</h2>
|
||||
<div class="hint">Die Gruppe bestimmt die Rechte eines Benutzers.</div>
|
||||
</div>
|
||||
{% if current_user.has_permission('users.create') %}
|
||||
<div class="flex gap-2">
|
||||
{% if ldap_enabled %}
|
||||
<button type="button" class="btn btn-secondary" data-open-modal="ldapAddModal" onclick="resetLdapSearch();">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
|
||||
Aus Active Directory hinzufügen
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Neuer Benutzer
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<div class="table-toolbar">
|
||||
<div class="search-input">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<input type="text" id="userSearch" placeholder="Benutzer durchsuchen…" oninput="filterTable('userSearch','usersTable')">
|
||||
</div>
|
||||
</div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="usersTable" data-sortable>
|
||||
<thead><tr>
|
||||
<th data-sort-key="username">Username</th>
|
||||
<th data-sort-key="firstname">Vorname</th>
|
||||
<th data-sort-key="lastname">Nachname</th>
|
||||
<th data-sort-key="group">Gruppe</th>
|
||||
<th style="width:1%;">Aktionen</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
{% set group_label = 'Admin' if u['is_admin'] else (u['group_names'] or '') %}
|
||||
{% set is_ldap = u['auth_source'] == 'ldap' %}
|
||||
<tr data-sort-username="{{ u['username']|lower }}" data-sort-firstname="{{ (u['first_name'] or '')|lower }}" data-sort-lastname="{{ (u['last_name'] or '')|lower }}" data-sort-group="{{ group_label|lower }}">
|
||||
<td class="cell-name">
|
||||
{{ u['username'] }}
|
||||
{% if is_ldap %}<span class="pill user" style="font-size:10px; padding:2px 7px;" title="Konto stammt aus Active Directory/LDAP, Passwort wird dort verwaltet">AD</span>{% endif %}
|
||||
{% if u['is_locked'] %}<span class="pill" style="font-size:10px; padding:2px 7px; background:var(--danger-dim); color:var(--danger);" title="Login für dieses Konto ist gesperrt">Gesperrt</span>{% endif %}
|
||||
{% if u['email'] %}<div class="text-faint" style="font-size:11px;">{{ u['email'] }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="text-dim">{{ u['first_name'] or '—' }}</td>
|
||||
<td class="text-dim">{{ u['last_name'] or '—' }}</td>
|
||||
<td>
|
||||
{% if u['is_admin'] %}
|
||||
<span class="pill admin">Admin</span>
|
||||
{% else %}
|
||||
<span class="text-dim">{{ u['group_names'] or '—' }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% set may_touch_target = current_user.is_admin or not u['is_admin'] %}
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
{% if current_user.has_permission('users.edit') and may_touch_target and not is_ldap %}
|
||||
<button class="icon-btn" title="Bearbeiten"
|
||||
onclick="openEditModal({{ u['id'] }}, '{{ u['username'] }}', '{{ u['first_name'] or '' }}', '{{ u['last_name'] or '' }}', '{{ u['email'] or '' }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has_permission('users.edit') and may_touch_target %}
|
||||
<button class="icon-btn" title="Gruppe zuweisen"
|
||||
onclick="openGroupModal({{ u['id'] }}, '{{ 'admin' if u['is_admin'] else (u['group_id'] or '') }}')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has_permission('users.edit') and may_touch_target and u['id'] != current_user.id %}
|
||||
<form method="post" data-confirm="„{{ u['username'] }}“ wirklich {{ 'entsperren' if u['is_locked'] else 'sperren' }}?">
|
||||
<input type="hidden" name="toggle_lock" value="{{ u['id'] }}">
|
||||
<button type="submit" class="icon-btn" title="{{ 'Entsperren' if u['is_locked'] else 'Sperren' }}">
|
||||
{% if u['is_locked'] %}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 019.9-1"/></svg>
|
||||
{% else %}
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_user.has_permission('users.edit') and may_touch_target %}
|
||||
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?">
|
||||
<input type="hidden" name="delete_user" value="{{ u['id'] }}">
|
||||
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr class="empty-row"><td colspan="5">Noch keine Benutzer vorhanden.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Neuer Benutzer -->
|
||||
<div class="modal-overlay" id="userModal">
|
||||
<div class="modal" style="max-width:1000px;">
|
||||
<form method="post" id="userForm">
|
||||
<div class="modal-header">
|
||||
<h3>Neuen Benutzer anlegen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Vorname</label><input type="text" name="first_name"></div>
|
||||
<div class="field"><label>Name</label><input type="text" name="last_name"></div>
|
||||
<div class="field"><label>Username</label><input type="text" name="username" required></div>
|
||||
<div class="field"><label>E-Mail</label><input type="email" name="email" placeholder="optional — ermöglicht Login per E-Mail"></div>
|
||||
<div class="field"><label>Passwort</label><input type="password" name="password" required></div>
|
||||
<div class="field">
|
||||
<label>Gruppe</label>
|
||||
<select name="group_id">
|
||||
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
|
||||
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="add_user" value="1" class="btn btn-primary">Anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Benutzer bearbeiten (nur lokale Konten -- AD/LDAP-Konten lassen
|
||||
sich nur sperren/entsperren und einer Gruppe zuweisen, siehe Tabelle) -->
|
||||
<div class="modal-overlay" id="editModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="editForm">
|
||||
<input type="hidden" name="user_id" id="edit_user_id">
|
||||
<div class="modal-header">
|
||||
<h3>Benutzer bearbeiten</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field"><label>Vorname</label><input type="text" name="first_name" id="edit_first_name"></div>
|
||||
<div class="field"><label>Name</label><input type="text" name="last_name" id="edit_last_name"></div>
|
||||
<div class="field"><label>Username</label><input type="text" name="username" id="edit_username" required></div>
|
||||
<div class="field"><label>E-Mail</label><input type="email" name="email" id="edit_email" placeholder="optional — ermöglicht Login per E-Mail"></div>
|
||||
<div class="field"><label>Neues Passwort</label>
|
||||
<input type="password" name="new_password" placeholder="Nur bei Änderung ausfüllen">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="edit_user" value="1" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Aus Active Directory hinzufügen (Vorab-Zuweisung vor dem ersten
|
||||
Login des AD-Benutzers) -->
|
||||
{% if ldap_enabled %}
|
||||
<div class="modal-overlay" id="ldapAddModal">
|
||||
<div class="modal">
|
||||
<form method="post" id="ldapAddForm">
|
||||
<input type="hidden" name="ldap_username" id="ldap_add_username">
|
||||
<div class="modal-header">
|
||||
<h3>Aus Active Directory hinzufügen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Suche</label>
|
||||
<input type="text" id="ldapSearchInput" placeholder="Name, Benutzername oder UPN eingeben …" autocomplete="off">
|
||||
<div class="field-hint" id="ldapSearchStatus">Mindestens 2 Zeichen eingeben.</div>
|
||||
</div>
|
||||
<div id="ldapSearchResults" style="max-height:240px; overflow-y:auto; display:flex; flex-direction:column; gap:4px;"></div>
|
||||
<div class="field" id="ldapAddGroupField" style="display:none;">
|
||||
<label>Gruppe für <span id="ldapAddSelectedName"></span></label>
|
||||
<select name="group_id" id="ldapAddGroupSelect">
|
||||
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
|
||||
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="ldap_add_user" value="1" class="btn btn-primary" id="ldapAddSubmit" disabled>Hinzufügen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Modal: Gruppe zuweisen (inkl. Admin als Auswahl) -->
|
||||
<div class="modal-overlay" id="groupModal">
|
||||
<div class="modal" style="max-width:380px;">
|
||||
<form method="post" id="groupForm">
|
||||
<input type="hidden" name="user_id" id="group_user_id">
|
||||
<div class="modal-header">
|
||||
<h3>Gruppe zuweisen</h3>
|
||||
<button type="button" class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="field">
|
||||
<label>Gruppe</label>
|
||||
<select name="group_id" id="group_select">
|
||||
<option value="">Keine Gruppe</option>
|
||||
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
|
||||
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
|
||||
</select>
|
||||
<div class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
|
||||
<button type="submit" name="assign_group" value="1" class="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function filterTable(inputId, tableId) {
|
||||
const q = document.getElementById(inputId).value.trim().toLowerCase();
|
||||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||||
if (row.classList.contains("empty-row")) return;
|
||||
row.style.display = row.innerText.toLowerCase().includes(q) ? "" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
function openEditModal(userId, username, firstName, lastName, email) {
|
||||
document.getElementById("edit_user_id").value = userId;
|
||||
document.getElementById("edit_username").value = username;
|
||||
document.getElementById("edit_first_name").value = firstName;
|
||||
document.getElementById("edit_last_name").value = lastName;
|
||||
document.getElementById("edit_email").value = email || "";
|
||||
document.querySelector("#editForm input[name='new_password']").value = "";
|
||||
PoeUI.openModal("editModal");
|
||||
}
|
||||
function openGroupModal(userId, groupChoice) {
|
||||
document.getElementById("group_user_id").value = userId;
|
||||
document.getElementById("group_select").value = groupChoice || "";
|
||||
PoeUI.openModal("groupModal");
|
||||
}
|
||||
|
||||
{% if ldap_enabled %}
|
||||
function resetLdapSearch() {
|
||||
document.getElementById("ldapSearchInput").value = "";
|
||||
document.getElementById("ldapSearchResults").innerHTML = "";
|
||||
document.getElementById("ldapSearchStatus").textContent = "Mindestens 2 Zeichen eingeben.";
|
||||
document.getElementById("ldapAddGroupField").style.display = "none";
|
||||
document.getElementById("ldap_add_username").value = "";
|
||||
document.getElementById("ldapAddSubmit").disabled = true;
|
||||
}
|
||||
|
||||
(function () {
|
||||
var input = document.getElementById("ldapSearchInput");
|
||||
var results = document.getElementById("ldapSearchResults");
|
||||
var status = document.getElementById("ldapSearchStatus");
|
||||
if (!input) return;
|
||||
var debounceTimer = null;
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
var q = input.value.trim();
|
||||
clearTimeout(debounceTimer);
|
||||
if (q.length < 2) {
|
||||
results.innerHTML = "";
|
||||
status.textContent = "Mindestens 2 Zeichen eingeben.";
|
||||
return;
|
||||
}
|
||||
status.textContent = "Suche …";
|
||||
debounceTimer = setTimeout(function () {
|
||||
fetch("{{ url_for('users_ldap_search') }}?q=" + encodeURIComponent(q))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!Array.isArray(data)) {
|
||||
status.textContent = data.error || "Fehler bei der Suche.";
|
||||
return;
|
||||
}
|
||||
results.innerHTML = "";
|
||||
if (!data.length) {
|
||||
status.textContent = "Keine Treffer (oder bereits lokal bekannt).";
|
||||
return;
|
||||
}
|
||||
status.textContent = data.length + " Treffer:";
|
||||
data.forEach(function (u) {
|
||||
var full = [u.first_name, u.last_name].filter(Boolean).join(" ");
|
||||
var row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "btn btn-secondary btn-sm";
|
||||
row.style.textAlign = "left";
|
||||
row.style.justifyContent = "flex-start";
|
||||
row.textContent = u.username + (full ? " — " + full : "") + (u.email ? " (" + u.email + ")" : "");
|
||||
row.addEventListener("click", function () {
|
||||
document.getElementById("ldap_add_username").value = u.username;
|
||||
document.getElementById("ldapAddSelectedName").textContent = u.username;
|
||||
document.getElementById("ldapAddGroupField").style.display = "";
|
||||
document.getElementById("ldapAddSubmit").disabled = false;
|
||||
});
|
||||
results.appendChild(row);
|
||||
});
|
||||
})
|
||||
.catch(function () { status.textContent = "Fehler bei der Suche."; });
|
||||
}, 300);
|
||||
});
|
||||
})();
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user