Geräte: SSH-Zugangsdaten mit Kategorien, PoE-Neustart nur wenn möglich

- credentials: neue Spalte 'category' (Switch/Linux-Client/
  Windows-Client/Router), Grundlage für die geplante SSH-basierte
  Wartungsfunktion (Bulk-Updates); Löschschutz prüft jetzt zusätzlich
  devices.credential_id, nicht mehr nur switches.credential_id
- devices: optionale SSH-Zugangsdaten + SSH-Port je Gerät, inkl.
  "Verbindung testen"-Terminal wie bei Switches
- Dashboard: PoE-Neustart-Button wird für Geräte ohne zugewiesenen
  Switch/Port komplett ausgeblendet (nicht nur ausgegraut), da für
  diese kein PoE-Reset über poe.sh möglich ist; Button bleibt
  ausschließlich für den PoE-Neustart über den Switch zuständig
- Schließen-Button im Geräte-Modal immer rechts, unabhängig davon
  welche Aktions-Buttons gerade sichtbar sind
- "Port" im Geräte-Formular zu "Switchport" umbenannt und von
  "SSH-Port" klar abgegrenzt (unterschiedliche Konzepte: physischer
  Switch-Port fürs PoE vs. TCP-Port für SSH)
- .btn:disabled greift jetzt echtes Grau statt nur reduzierter
  Deckkraft der jeweiligen Button-Farbe

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 10:34:30 +02:00
co-authored by Claude Sonnet 5
parent d3cf78592f
commit 63ca7782ba
5 changed files with 426 additions and 43 deletions
+111 -29
View File
@@ -84,6 +84,21 @@ AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")
# explizit eingetragen werden muss. # explizit eingetragen werden muss.
SWITCH_DEFAULT_SSH_PORT = 22 SWITCH_DEFAULT_SSH_PORT = 22
ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"} ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
# Zugangsdaten-Kategorien — legt der Admin explizit fest (siehe
# _ensure_schema()-Kommentar für die Begründung gegen automatische
# Ping-basierte Erkennung). Nur "linux" ist aktuell für eine Aktion nutzbar
# (SSH-Update unter "Wartung"); die anderen sind reine Einordnung, z.B. für
# spätere Erweiterungen (Windows/PowerShell folgt separat).
CREDENTIAL_CATEGORIES = [
("switch", "Switch"),
("linux", "Linux-Client"),
("windows", "Windows-Client"),
("router", "Router/Sonstiges"),
]
CREDENTIAL_CATEGORY_KEYS = {c[0] for c in CREDENTIAL_CATEGORIES}
# Kategorie, für die die SSH-Update-Aktion unter "Wartung" angeboten wird.
DEVICE_MAINTENANCE_CATEGORY = "linux"
os.makedirs(AVATAR_DIR, exist_ok=True) os.makedirs(AVATAR_DIR, exist_ok=True)
app = Flask(__name__) app = Flask(__name__)
@@ -661,9 +676,20 @@ def _ensure_schema():
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL, name TEXT UNIQUE NOT NULL,
username TEXT NOT NULL, username TEXT NOT NULL,
password TEXT NOT NULL password TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'switch'
) )
""") """)
# Kategorie (Switch/Linux-Client/Windows-Client/Router-Sonstiges) —
# bestimmt u.a., ob für ein Gerät mit diesen Zugangsdaten die
# SSH-Update-Aktion unter "Wartung" angeboten wird (siehe
# DEVICE_MAINTENANCE_CATEGORY). Bewusst KEINE automatische Erkennung
# per Ping o.ä. — ein reiner TTL-Hinweis ist keine verlässliche
# Grundlage dafür, unbeaufsichtigt Systembefehle auszuführen; der Admin
# legt die Kategorie explizit fest.
credential_cols = {row["name"] for row in conn.execute("PRAGMA table_info(credentials)").fetchall()}
if "category" not in credential_cols:
conn.execute("ALTER TABLE credentials ADD COLUMN category TEXT NOT NULL DEFAULT 'switch'")
switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()} switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()}
if "credential_id" not in switch_cols: if "credential_id" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER") conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER")
@@ -676,6 +702,15 @@ def _ensure_schema():
if "ssh_port" not in switch_cols: if "ssh_port" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER") conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER")
# Optionale SSH-Anbindung eines Clients (devices) — analog zu Switchen,
# damit z.B. ein Linux-Client für die Wartungs-/Update-Aktion erreichbar
# ist. NULL/kein credential_id = kein SSH-Zugriff für dieses Gerät.
device_ssh_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
if "credential_id" not in device_ssh_cols:
conn.execute("ALTER TABLE devices ADD COLUMN credential_id INTEGER")
if "ssh_port" not in device_ssh_cols:
conn.execute("ALTER TABLE devices ADD COLUMN ssh_port INTEGER")
# DHCP: eigene Options-Definitionen + Werte (global/per-Client, # DHCP: eigene Options-Definitionen + Werte (global/per-Client,
# siehe create_db.py für die ausführliche Begründung von device_mac=''). # siehe create_db.py für die ausführliche Begründung von device_mac='').
conn.execute(""" conn.execute("""
@@ -2874,11 +2909,25 @@ def devices():
flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger")
return redirect(url_for("devices")) return redirect(url_for("devices"))
# SSH-Anbindung optional — die meisten Geräte sind keine per SSH
# erreichbaren Hosts, nur wer bewusst Zugangsdaten auswählt/anlegt
# bekommt sie zugeordnet (siehe Wartungsseite).
ssh_port, port_error = _parse_ssh_port(request.form)
credential_id, cred_error = (None, None)
if request.form.get("credential_choice"):
credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux")
if port_error:
flash(port_error, "danger")
return redirect(url_for("devices"))
if cred_error:
flash(cred_error, "danger")
return redirect(url_for("devices"))
try: try:
conn.execute( conn.execute(
"INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) " "INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active, credential_id, ssh_port) "
"VALUES (?, ?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(mac, rpi_ip, port, name, switch_hostname, is_active), (mac, rpi_ip, port, name, switch_hostname, is_active, credential_id, ssh_port),
) )
touch_record(conn, "devices", "mac", mac) touch_record(conn, "devices", "mac", mac)
conn.commit() conn.commit()
@@ -2939,10 +2988,21 @@ def devices():
flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger") flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger")
return redirect(url_for("devices")) return redirect(url_for("devices"))
ssh_port, port_error = _parse_ssh_port(request.form)
credential_id, cred_error = (None, None)
if request.form.get("credential_choice"):
credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux")
if port_error:
flash(port_error, "danger")
return redirect(url_for("devices"))
if cred_error:
flash(cred_error, "danger")
return redirect(url_for("devices"))
try: try:
conn.execute( conn.execute(
"UPDATE devices SET mac=?, rpi_ip=?, port=?, name=? WHERE mac=?", "UPDATE devices SET mac=?, rpi_ip=?, port=?, name=?, credential_id=?, ssh_port=? WHERE mac=?",
(mac, rpi_ip, port, name, old_mac), (mac, rpi_ip, port, name, credential_id, ssh_port, old_mac),
) )
touch_record(conn, "devices", "mac", mac) touch_record(conn, "devices", "mac", mac)
conn.commit() conn.commit()
@@ -2973,13 +3033,15 @@ def devices():
device_rows = conn.execute(""" device_rows = conn.execute("""
SELECT devices.mac, devices.rpi_ip, devices.port, devices.name, devices.is_active, SELECT devices.mac, devices.rpi_ip, devices.port, devices.name, devices.is_active,
devices.credential_id, devices.ssh_port,
switches.hostname AS switch_hostname switches.hostname AS switch_hostname
FROM devices FROM devices
LEFT JOIN switches ON devices.switch_hostname = switches.hostname LEFT JOIN switches ON devices.switch_hostname = switches.hostname
ORDER BY switches.hostname ASC, devices.name ASC ORDER BY switches.hostname ASC, devices.name ASC
""").fetchall() """).fetchall()
all_credentials = conn.execute("SELECT id, name, username, category FROM credentials ORDER BY name ASC").fetchall()
conn.close() conn.close()
return render_template("devices.html", devices=device_rows, switches=switches) return render_template("devices.html", devices=device_rows, switches=switches, all_credentials=all_credentials)
@app.route("/devices/toggle/<mac>", methods=["POST"]) @app.route("/devices/toggle/<mac>", methods=["POST"])
@@ -3011,13 +3073,16 @@ def toggle_device(mac):
# Switches # Switches
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _resolve_credential_choice(conn): def _resolve_credential_choice(conn, default_category="switch"):
""" """
Liest die Zugangsdaten-Auswahl aus einem Switch-Formular: entweder eine Liest die Zugangsdaten-Auswahl aus einem Switch-/Client-Formular:
bestehende credential_id, oder (choice == "new") legt direkt aus dem entweder eine bestehende credential_id, oder (choice == "new") legt
Switch-Formular heraus neue Zugangsdaten an — damit man beim Anlegen direkt aus dem Formular heraus neue Zugangsdaten an — damit man beim
eines Switches nicht zuerst zu "Zugangsdaten" wechseln muss. Anlegen nicht zuerst zu "Zugangsdaten" wechseln muss. default_category
Gibt (credential_id, error_message) zurück; error_message ist None bei Erfolg. wird nur für neu angelegte Zugangsdaten verwendet (z.B. "linux", wenn
aus dem Client-Formular heraus angelegt), bestehende behalten ihre
eigene Kategorie. Gibt (credential_id, error_message) zurück;
error_message ist None bei Erfolg.
""" """
choice = request.form.get("credential_choice", "") choice = request.form.get("credential_choice", "")
if choice == "new": if choice == "new":
@@ -3030,8 +3095,8 @@ def _resolve_credential_choice(conn):
return None, "Für neue Zugangsdaten müssen Name, Username und Passwort ausgefüllt sein!" return None, "Für neue Zugangsdaten müssen Name, Username und Passwort ausgefüllt sein!"
try: try:
cur = conn.execute( cur = conn.execute(
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)", "INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)",
(name, username, encrypt_password(password)), (name, username, encrypt_password(password), default_category),
) )
return cur.lastrowid, None return cur.lastrowid, None
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
@@ -3176,11 +3241,14 @@ def credentials():
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
category = request.form.get("category", "").strip()
if category not in CREDENTIAL_CATEGORY_KEYS:
category = "switch"
if name and username and password: if name and username and password:
try: try:
conn.execute( conn.execute(
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)", "INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)",
(name, username, encrypt_password(password)), (name, username, encrypt_password(password), category),
) )
conn.commit() conn.commit()
log_action("credential.create", name) log_action("credential.create", name)
@@ -3198,17 +3266,20 @@ def credentials():
name = request.form.get("name", "").strip() name = request.form.get("name", "").strip()
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
new_password = request.form.get("password", "") new_password = request.form.get("password", "")
category = request.form.get("category", "").strip()
if category not in CREDENTIAL_CATEGORY_KEYS:
category = "switch"
if name and username: if name and username:
try: try:
if new_password: if new_password:
conn.execute( conn.execute(
"UPDATE credentials SET name=?, username=?, password=? WHERE id=?", "UPDATE credentials SET name=?, username=?, password=?, category=? WHERE id=?",
(name, username, encrypt_password(new_password), cred_id), (name, username, encrypt_password(new_password), category, cred_id),
) )
else: else:
conn.execute( conn.execute(
"UPDATE credentials SET name=?, username=? WHERE id=?", "UPDATE credentials SET name=?, username=?, category=? WHERE id=?",
(name, username, cred_id), (name, username, category, cred_id),
) )
conn.commit() conn.commit()
log_action("credential.edit", name) log_action("credential.edit", name)
@@ -3224,10 +3295,11 @@ def credentials():
flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger") flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger")
return redirect(url_for("credentials")) return redirect(url_for("credentials"))
cred_id = request.form.get("delete_credential") cred_id = request.form.get("delete_credential")
used_by = conn.execute("SELECT hostname FROM switches WHERE credential_id=?", (cred_id,)).fetchall() used_by_switches = conn.execute("SELECT hostname FROM switches WHERE credential_id=?", (cred_id,)).fetchall()
if used_by: used_by_devices = conn.execute("SELECT name FROM devices WHERE credential_id=?", (cred_id,)).fetchall()
names = ", ".join(s["hostname"] for s in used_by) if used_by_switches or used_by_devices:
flash(f"Diese Zugangsdaten werden noch von folgenden Switchen verwendet: {names}", "danger") names = ", ".join([s["hostname"] for s in used_by_switches] + [d["name"] for d in used_by_devices])
flash(f"Diese Zugangsdaten werden noch verwendet von: {names}", "danger")
else: else:
cred_row = conn.execute("SELECT name FROM credentials WHERE id=?", (cred_id,)).fetchone() cred_row = conn.execute("SELECT name FROM credentials WHERE id=?", (cred_id,)).fetchone()
conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,)) conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,))
@@ -3236,15 +3308,17 @@ def credentials():
flash("Zugangsdaten gelöscht.", "success") flash("Zugangsdaten gelöscht.", "success")
credential_rows = conn.execute(""" credential_rows = conn.execute("""
SELECT credentials.id, credentials.name, credentials.username, SELECT credentials.id, credentials.name, credentials.username, credentials.category,
COUNT(switches.hostname) AS switch_count COUNT(DISTINCT switches.hostname) AS switch_count,
COUNT(DISTINCT devices.mac) AS device_count
FROM credentials FROM credentials
LEFT JOIN switches ON switches.credential_id = credentials.id LEFT JOIN switches ON switches.credential_id = credentials.id
LEFT JOIN devices ON devices.credential_id = credentials.id
GROUP BY credentials.id GROUP BY credentials.id
ORDER BY credentials.name ASC ORDER BY credentials.name ASC
""").fetchall() """).fetchall()
conn.close() conn.close()
return render_template("credentials.html", credentials=credential_rows) return render_template("credentials.html", credentials=credential_rows, categories=CREDENTIAL_CATEGORIES)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -3629,7 +3703,9 @@ def restart_device(mac):
return jsonify({"success": False, "message": "Keine Berechtigung für den PoE-Neustart."}), 403 return jsonify({"success": False, "message": "Keine Berechtigung für den PoE-Neustart."}), 403
conn = get_db_connection() conn = get_db_connection()
device = conn.execute("SELECT mac, name, is_active FROM devices WHERE mac=?", (mac,)).fetchone() device = conn.execute(
"SELECT mac, name, is_active, switch_hostname, port FROM devices WHERE mac=?", (mac,)
).fetchone()
conn.close() conn.close()
if not device: if not device:
@@ -3638,6 +3714,12 @@ def restart_device(mac):
if device["is_active"] == 0: if device["is_active"] == 0:
return jsonify({"success": False, "message": f"{device['name']} ist deaktiviert."}), 400 return jsonify({"success": False, "message": f"{device['name']} ist deaktiviert."}), 400
# Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset auslösen
# (siehe poe.sh: der SSH-Restart-Block wird dort übersprungen) — dann
# lieber gar nicht erst einen "Neustart gestartet"-Erfolg vorgaukeln.
if not device["switch_hostname"] or not device["port"]:
return jsonify({"success": False, "message": f"{device['name']} hat keinen Switch/Port zugewiesen — PoE-Neustart nicht möglich."}), 400
try: try:
subprocess.Popen( subprocess.Popen(
["/bin/bash", POE_SCRIPT, "restart", mac], ["/bin/bash", POE_SCRIPT, "restart", mac],
+8 -1
View File
@@ -409,7 +409,14 @@ button { font-family: inherit; }
.btn-sm { padding: 6px 12px; font-size: 12.5px; border-radius: 8px; } .btn-sm { padding: 6px 12px; font-size: 12.5px; border-radius: 8px; }
.btn-block { width: 100%; } .btn-block { width: 100%; }
.btn:disabled { opacity: 0.55; cursor: not-allowed; } .btn:disabled {
opacity: 0.65;
cursor: not-allowed;
background: var(--bg-card-hover) !important;
color: var(--text-dim) !important;
border-color: var(--border) !important;
box-shadow: none !important;
}
.icon-btn { .icon-btn {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
+31 -5
View File
@@ -3,6 +3,7 @@
{% set can_create = current_user.has_permission('credentials.create') %} {% set can_create = current_user.has_permission('credentials.create') %}
{% set can_edit = current_user.has_permission('credentials.edit') %} {% set can_edit = current_user.has_permission('credentials.edit') %}
{% set can_delete = 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_title %}Zugangsdaten{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
@@ -27,20 +28,29 @@
<thead><tr> <thead><tr>
<th data-sort-key="name">Name</th> <th data-sort-key="name">Name</th>
<th data-sort-key="username">Username</th> <th data-sort-key="username">Username</th>
<th data-sort-key="category">Kategorie</th>
<th data-sort-key="usage">Verwendet von</th> <th data-sort-key="usage">Verwendet von</th>
<th style="width:1%;">Aktionen</th> <th style="width:1%;">Aktionen</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for c in credentials %} {% for c in credentials %}
<tr data-sort-name="{{ c['name']|lower }}" data-sort-username="{{ c['username']|lower }}" data-sort-usage="{{ c['switch_count'] }}"> {% 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="cell-name">{{ c['name'] }}</td>
<td class="mono">{{ c['username'] }}</td> <td class="mono">{{ c['username'] }}</td>
<td class="text-dim">{{ c['switch_count'] }} Switch{{ 'e' if c['switch_count'] != 1 else '' }}</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> <td>
<div class="row-actions"> <div class="row-actions">
{% if can_edit %} {% if can_edit %}
<button class="icon-btn" title="Bearbeiten" <button class="icon-btn" title="Bearbeiten"
onclick="openEditCredentialModal({{ c['id'] }}, '{{ c['name'] }}', '{{ c['username'] }}')"> 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> <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>
{% endif %} {% endif %}
@@ -56,7 +66,7 @@
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr class="empty-row"><td colspan="4">Noch keine Zugangsdaten vorhanden.</td></tr> <tr class="empty-row"><td colspan="5">Noch keine Zugangsdaten vorhanden.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -80,6 +90,14 @@
<div class="field"><label>Username</label> <div class="field"><label>Username</label>
<input type="text" name="username" required placeholder="z.B. admin"> <input type="text" name="username" required placeholder="z.B. admin">
</div> </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> <div class="field"><label>Passwort</label>
<input type="password" id="password_add" name="password" required> <input type="password" id="password_add" name="password" required>
</div> </div>
@@ -115,6 +133,13 @@
<div class="field"><label>Username</label> <div class="field"><label>Username</label>
<input type="text" name="username" id="edit_cred_username" required> <input type="text" name="username" id="edit_cred_username" required>
</div> </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> <div class="field"><label>Neues Passwort</label>
<input type="password" id="password_edit" name="password" placeholder="Nur bei Änderung ausfüllen"> <input type="password" id="password_edit" name="password" placeholder="Nur bei Änderung ausfüllen">
</div> </div>
@@ -136,10 +161,11 @@
{% block scripts %} {% block scripts %}
<script> <script>
function openEditCredentialModal(id, name, username) { function openEditCredentialModal(id, name, username, category) {
document.getElementById("edit_cred_id").value = id; document.getElementById("edit_cred_id").value = id;
document.getElementById("edit_cred_name").value = name; document.getElementById("edit_cred_name").value = name;
document.getElementById("edit_cred_username").value = username; document.getElementById("edit_cred_username").value = username;
document.getElementById("edit_cred_category").value = category;
document.getElementById("password_edit").value = ""; document.getElementById("password_edit").value = "";
document.getElementById("password_confirm_edit").value = ""; document.getElementById("password_confirm_edit").value = "";
PoeUI.openModal("editCredentialModal"); PoeUI.openModal("editCredentialModal");
+261 -6
View File
@@ -1,4 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/vendor/xterm.css') }}">
{% endblock %}
{% set active_page = "devices" %} {% set active_page = "devices" %}
{% set can_toggle = current_user.has_permission('devices.edit') %} {% set can_toggle = current_user.has_permission('devices.edit') %}
{% set can_create = current_user.has_permission('devices.create') %} {% set can_create = current_user.has_permission('devices.create') %}
@@ -39,7 +42,7 @@
<th data-sort-key="ip">IP-Adresse</th> <th data-sort-key="ip">IP-Adresse</th>
<th data-sort-key="mac">MAC-Adresse</th> <th data-sort-key="mac">MAC-Adresse</th>
<th data-sort-key="switch">Switch</th> <th data-sort-key="switch">Switch</th>
<th data-sort-key="port">Port</th> <th data-sort-key="port">Switchport</th>
{% if can_toggle %}<th>Status</th>{% endif %} {% if can_toggle %}<th>Status</th>{% endif %}
{% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %} {% if show_actions_col %}<th style="width:1%;">Aktionen</th>{% endif %}
</tr> </tr>
@@ -65,7 +68,7 @@
<div class="row-actions"> <div class="row-actions">
{% if can_edit %} {% if can_edit %}
<button class="icon-btn" title="Bearbeiten" <button class="icon-btn" title="Bearbeiten"
onclick="openEditDeviceModal('{{ d['mac'] }}','{{ d['name'] }}','{{ d['rpi_ip'] }}','{{ d['port'] or '' }}')"> onclick="openEditDeviceModal('{{ d['mac'] }}','{{ d['name'] }}','{{ d['rpi_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> <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>
<button class="icon-btn" title="Switch ändern" <button class="icon-btn" title="Switch ändern"
@@ -115,8 +118,9 @@
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF"> <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 class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
</div> </div>
<div class="field"><label>Port</label> <div class="field"><label>Switchport</label>
<input type="text" name="port" placeholder="z.B. 3"> <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>
<div class="field"><label>Switch (optional)</label> <div class="field"><label>Switch (optional)</label>
<select name="switch_hostname"> <select name="switch_hostname">
@@ -124,8 +128,42 @@
{% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %} {% for sw in switches %}<option value="{{ sw['hostname'] }}">{{ sw['hostname'] }}</option>{% endfor %}
</select> </select>
</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" 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>
<div class="modal-footer"> <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="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button> <button type="submit" class="btn btn-primary">Hinzufügen</button>
</div> </div>
@@ -137,7 +175,7 @@
{% if can_edit %} {% if can_edit %}
<!-- Modal: Bearbeiten --> <!-- Modal: Bearbeiten -->
<div class="modal-overlay" id="editDeviceModal"> <div class="modal-overlay" id="editDeviceModal">
<div class="modal"> <div class="modal" style="max-width:1000px;">
<form method="post" onsubmit="return validateDeviceForm(this);"> <form method="post" onsubmit="return validateDeviceForm(this);">
<input type="hidden" name="edit_device" value="1"> <input type="hidden" name="edit_device" value="1">
<input type="hidden" name="old_mac" id="edit_old_mac"> <input type="hidden" name="old_mac" id="edit_old_mac">
@@ -157,11 +195,46 @@
<input type="text" name="mac" id="edit_mac" required> <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 class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
</div> </div>
<div class="field"><label>Port</label> <div class="field"><label>Switchport</label>
<input type="text" name="port" id="edit_port"> <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> </div>
<div class="modal-footer"> <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="button" class="btn btn-secondary" data-close-modal>Abbrechen</button>
<button type="submit" class="btn btn-primary">Speichern</button> <button type="submit" class="btn btn-primary">Speichern</button>
</div> </div>
@@ -197,20 +270,58 @@
</div> </div>
{% endif %} {% 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()">&times;</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 %} {% endblock %}
{% block scripts %} {% 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> <script>
function resetDeviceForm() { function resetDeviceForm() {
document.querySelector("#deviceModal form").reset(); document.querySelector("#deviceModal form").reset();
resetCredentialChoice("add");
} }
function openEditDeviceModal(mac, name, ip, port) { function openEditDeviceModal(mac, name, ip, port, sshPort, credentialId) {
document.getElementById("edit_old_mac").value = mac; document.getElementById("edit_old_mac").value = mac;
document.getElementById("edit_name").value = name; document.getElementById("edit_name").value = name;
document.getElementById("edit_ip").value = ip; document.getElementById("edit_ip").value = ip;
document.getElementById("edit_mac").value = mac; document.getElementById("edit_mac").value = mac;
document.getElementById("edit_port").value = port; document.getElementById("edit_port").value = port;
document.getElementById("edit_ssh_port").value = sshPort || "";
document.getElementById("edit_credential_choice").value = credentialId || "";
PoeUI.openModal("editDeviceModal"); PoeUI.openModal("editDeviceModal");
resetCredentialChoice("edit");
} }
function openSwitchModal(mac, switchHostname) { function openSwitchModal(mac, switchHostname) {
document.getElementById("switch_mac").value = mac; document.getElementById("switch_mac").value = mac;
@@ -231,19 +342,163 @@ function validateMAC(input) {
input.classList.toggle("is-invalid", !ok); input.classList.toggle("is-invalid", !ok);
return 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) { function validateDeviceForm(form) {
const ipInput = form.querySelector("input[name='rpi_ip']"); const ipInput = form.querySelector("input[name='rpi_ip']");
const macInput = form.querySelector("input[name='mac']"); const macInput = form.querySelector("input[name='mac']");
let valid = true; let valid = true;
if (ipInput) valid = validateIP(ipInput) && valid; if (ipInput) valid = validateIP(ipInput) && valid;
if (macInput) valid = validateMAC(macInput) && valid; if (macInput) valid = validateMAC(macInput) && valid;
valid = validateNewCredentialPassword(form) && valid;
return valid; return valid;
} }
document.addEventListener("input", (e) => { document.addEventListener("input", (e) => {
if (e.target.name === "rpi_ip") validateIP(e.target); if (e.target.name === "rpi_ip") validateIP(e.target);
if (e.target.name === "mac") validateMAC(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
// Muster in switches.html, nur mit rpi_ip statt ip als Feldname.
// -------------------------------------------------------------------------
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='rpi_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) { function toggleDevice(mac, checkbox) {
checkbox.disabled = true; checkbox.disabled = true;
fetch(`/devices/toggle/${mac}`, { method: "POST" }) fetch(`/devices/toggle/${mac}`, { method: "POST" })
+15 -2
View File
@@ -38,7 +38,6 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
{% if current_user.has_permission('devices.edit') %} {% if current_user.has_permission('devices.edit') %}
<button type="button" class="btn btn-success" id="activateButton" style="display:none;"> <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> <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>
@@ -51,6 +50,11 @@
Neustarten Neustarten
</button> </button>
{% endif %} {% 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> </div>
</div> </div>
@@ -136,7 +140,16 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("deviceChecked").innerText = this.dataset.checked || "-"; document.getElementById("deviceChecked").innerText = this.dataset.checked || "-";
const pill = this.querySelector(".pill"); const pill = this.querySelector(".pill");
document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-"; document.getElementById("deviceStatus").innerText = pill ? pill.innerText.trim() : "-";
if (restartButton) restartButton.style.display = isActive ? "" : "none"; 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" : ""; if (activateButton) activateButton.style.display = isActive ? "none" : "";
PoeUI.openModal("deviceModal"); PoeUI.openModal("deviceModal");
}); });