ctaas Netzwerk-Monitor/Netzwerkscanner:

© ctaas.de V2026-06-04

Diese Dokumentation beschreibt die Einrichtung und Funktionsweise eines ressourcenschonenden live Netzwerkmonitors zur Überwachung des Online-Status von lokalen Geräten.
Das System eignet sich für den Betrieb unter Linux, sofern ein Webserver, PHP und Python 3 zur Verfügung stehen.
Diese Anleitung basiert auf einer minimierten Installation von Ubuntu Server (Version 24.04 LTS, 26.04 LTS und neuer).

Das Gesamtsystem besteht aus folgenden Komponenten:
  1. Einem im Hintergrund laufenden Python-Backend (Scanner).
  2. Einer zentralen Konfigurationsdatei im CSV-Format, die über einen grafischen PHP-Editor gepflegt werden kann.
  3. Einem HTML-Frontend, das die Scan-Ergebnisse per AJAX (Fetch API) alle 10 Sekunden live und ohne vollständigen Seiten-Reload visualisiert.
Das System trennt Daten und Layout strikt voneinander. Nur das Hauptskript im Hintergrund benötigt Python.

1. Einrichtung des Webservers (lighttpd)

Zur Darstellung der Weboberfläche im lokalen Netzwerk wird ein Webserver benötigt dieser sollte möglichst zuerst eingerichtet werden weil die nachfolgenden Dateien im Webspace abgelegt werden.
Es wird die Verwendung von lighttpd empfohlen. Dieser ist im Vergleich zu Apache extrem leichtgewichtig, benötigt weniger Abhängigkeiten,
schont die Systemressourcen und reicht für diesen Einsatzzweck vollständig aus.

1.1 Installation von lighttpd

Führen Sie zur Installation des Webservers folgenden Befehl aus:
apt update
apt install lighttpd


1.2 Konfiguration anpassen

Die Konfigurationsdatei des Webservers entsprechend angepasst werden.
Nutzen Sie dazu je nach Umgebung den passenden Texteditor:
nano  /etc/lighttpd/lighttpd.conf (im Terminal auf Systemen ohne grafische Benutzeroberfläche)
pluma /etc/lighttpd/lighttpd.conf (auf Systemen mit installierter grafischer Desktop-Umgebung)


Passen Sie die Konfiguration im Bereich des Dokumenten-Wurzelverzeichnisses (Document-Root) wie folgt an:
...
server.modules = (
"mod_indexfile",
"mod_access",
"mod_alias",
"mod_redirect",
)

server.document-root = "/var/www/html/netzwerk-monitor.htm"
server.upload-dirs = ( "/var/cache/lighttpd/uploads" )
server.errorlog = "/var/log/lighttpd/error.log"
server.pid-file = "/var/run/lighttpd.pid"
server.username = "www-data"
server.groupname = "www-data"
server.port = 80 ...


1.3 Aktivierung und Firewall-Freigabe

Nachdem die Konfiguration gespeichert wurde, muss der Dienst neu gestartet werden, um die Änderungen zu übernehmen.
Zudem muss der HTTP-Port 80 in der Firewall freigegeben werden, damit andere Computer im Netzwerk auf die Seite zugreifen können:
systemctl restart lighttpd
ufw allow 80/tcp


2. Das Python-Hauptskript (Scanner)

Damit das Python-Skript auf einer minimierten Ubuntu-Server-Installation fehlerfrei ausgeführt werden kann,
müssen die Paketquellen aktualisiert und die Laufzeitumgebung sowie das Ping-Werkzeug installiert werden.
Führen Sie dazu folgende Befehle im Terminal aus:
apt update
apt install python3 iputils-ping


Das folgende Skript arbeitet wie folgt:
Es pingt in einem festen Intervall von 10 Sekunden alle IP-Adressen an, die in der Konfigurationsdatei hinterlegt sind.
Antwortet ein Gerät (z. B. ein PC, Drucker, Smartphone ...) auf den Ping-Befehl, wird es im System als "Online" markiert.
Durch den erfolgreichen Netzwerkverkehr wird gleichzeitig der lokale ARP-Cache des Betriebssystems mit den aktuellen MAC-Adressen der aktiven Geräte befüllt.
Das Skript liest diese MAC-Adressen aus und führt sie mit den Gerätedaten zusammen.

Die gesammelten Ergebnisse werden anschließend in der Datei 'netzwerk-scan.json' atomar sicher gespeichert.

Legt das Scan-Skript am einfachsten erstmal direkt im Webspace-Order ab.
Wenn Sie das Skript wo anders speichern müssen die Pfade im Skript entsprechend angepasst werden.
Für Test erste Installation empfehle ich die Ablage direkt im Webspace /var/www/html-Ordner.
cd /var/www/html
nano  netzwerk-monitor.py
pluma netzwerk-monitor.py


Hier dann den folgenden Inhalt in der 'netzwerk-monitor.py' einfügen:
#!/usr/bin/env python3
import csv
import subprocess
import os
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from zoneinfo import ZoneInfo

CSV_PATH = '/var/www/html/netzwerk-hosts.csv'
JSON_PATH = '/var/www/html/netzwerk-scan.json'
MAX_WORKERS = 15

def read_hosts(csv_path):
    ip_info = {}
    mac_info = {}
   
    with open(csv_path, newline='', encoding='utf-8-sig') as csvfile:
        reader = csv.DictReader(csvfile)
        for row in reader:
            ip = row.get('ip', '').strip()
            mac = row.get('mac', '').strip().upper().replace('-', ':')
            name = row.get('pc_name', '').strip()
            user = row.get('username', '').strip()
           
            # IPs (inklusive der DHCP-Platzhalter) in die IP-Liste
            if ip:
                ip_info[ip] = {'pc_name': name, 'username': user}
            # Reine MAC-Einträge (z.B. Handys) in die MAC-Liste
            elif mac:
                mac_info[mac] = {'pc_name': name, 'username': user}
               
    return ip_info, mac_info

def ping_host(ip):
    try:
        result = subprocess.run(
            ['ping', '-c', '1', '-W', '1', ip],
            stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
        )
        return ip, (result.returncode == 0)
    except Exception:
        return ip, False

def get_arp_table():
    arp_cache = {}
    try:
        with open('/proc/net/arp', 'r') as f:
            next(f) # Überspringe Kopfzeile
            for line in f:
                parts = line.split()
                if len(parts) >= 4:
                    ip = parts[0]
                    mac = parts[3].upper().replace('-', ':')
                    if mac != '00:00:00:00:00:00':
                        arp_cache[ip] = mac
    except Exception:
        pass
    return arp_cache

def ip_to_tuple(ip):
    try:
        return tuple(int(part) for part in ip.split('.'))
    except:
        return (0,0,0,0)

def main():
    if not os.path.isfile(CSV_PATH):
        print(f"Fehler: Datei {CSV_PATH} nicht gefunden.")
        return

    ip_info, mac_info = read_hosts(CSV_PATH)
    all_ips_to_ping = list(ip_info.keys())

    ping_results = {}
    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
        futures = {executor.submit(ping_host, ip): ip for ip in all_ips_to_ping}
        for future in as_completed(futures):
            ip, is_online = future.result()
            ping_results[ip] = is_online

    # ARP Cache auslesen, der durch die Pings frisch befüllt wurde
    arp_cache = get_arp_table()
   
    output_data = []
    online_count = 0
    total_count = len(ping_results)

    for ip, is_online in ping_results.items():
        status = 'Online' if is_online else 'Offline'
        if is_online:
            online_count += 1
       
        current_mac = arp_cache.get(ip, '')
       
        # Standardwerte aus der CSV übernehmen (IP-basiert)
        pc_name = ip_info[ip]['pc_name']
        username = ip_info[ip]['username']

        # Wenn das Gerät online ist und die MAC in unseren bekannten MACs auftaucht: Überschreiben
        if current_mac and current_mac in mac_info:
            pc_name = mac_info[current_mac]['pc_name']
            username = mac_info[current_mac]['username']

        output_data.append({
            'pc_name': pc_name,
            'username': username,
            'ip': ip,
            'mac': current_mac,
            'status': status
        })

    # Sortiere nach IP
    output_data.sort(key=lambda x: ip_to_tuple(x['ip']))

    current_time = datetime.now(ZoneInfo("Europe/Berlin"))
    update_time_str = current_time.strftime('%Y-%m-%d / %H:%M:%S Uhr')

    json_payload = {
        'update_time': update_time_str,
        'online_count': online_count,
        'total_count': total_count,
        'devices': output_data
    }

    # Atomares Schreiben (verhindert Race Conditions mit dem Webserver)
    tmp_json_path = JSON_PATH + '.tmp'
    with open(tmp_json_path, 'w', encoding='utf-8') as f:
        json.dump(json_payload, f, ensure_ascii=False, indent=2)
   
    # Datei per 'mv' austauschen
    os.replace(tmp_json_path, JSON_PATH)

    print(f"JSON-Datei '{JSON_PATH}' wurde atomar aktualisiert.")

if __name__ == '__main__':
    main()


2.1 Das Skript ausführbar machen und manuell starten

Rechte zum ausführen hinzufügen:
chmod +x /var/www/html/netzwerk-monitor.py


2.2 Automatischer Start und Endlosschleife

Um das Skript dauerhaft in einer Schleife laufen zu lassen, gibt es zwei Möglichkeiten.

Variante A:
Start über ein einfaches Bash-Skript (schnelle Variante)
Sie können ein separates Steuerungsskript z. B. '_starte-netzwerk-monitor.sh' im gleichen Verzeichnis anlegen:
nano  /var/www/html/_starte-netzwerk-monitor.sh
pluma /var/www/html/_starte-netzwerk-monitor.sh


Fügen Sie dort folgenden Inhalt ein:
#!/bin/bash
echo "Netzwerk-Monitor gestartet. Beenden mit Strg+C"
while true; do
    python3 /var/www/html/netzwerk-monitor.py
    sleep 10
done


Skript ausfürhbar kennzeichnen und starten:
chmod +x /var/www/html/_starte-netzwerk-monitor.sh
./var/www/html/_starte-netzwerk-monitor.sh

Beachten Sie, dass diese Variante abbricht, sobald die SSH-Sitzung oder das Terminalfenster geschlossen wird.

Variante B:
Einrichtung als systemd-Service (Empfohlene Profilösung)
Für einen dauerhaften und ausfallsicheren Betrieb im Hintergrund sollte das Skript als Systemdienst (Service) eingerichtet werden.
Der Monitor startet dann nach einem Server-Neustart automatisch und bei eventuellen Fehlern selbstständig neu.

Erstellen Sie dazu eine neue Dienst-Konfigurationsdatei:
nano  /etc/systemd/system/netzwerk-monitor.service
pluma /etc/systemd/system/netzwerk-monitor.service


Fügen Sie folgenden Inhalt ein:
[Unit]
Description=Netzwerk Monitor Python Skript
After=network.target

[Service]
Type=simple
WorkingDirectory=/var/www/html
ExecStart=/usr/bin/python3 /var/www/html/netzwerk-monitor.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target


Aktivieren und starten Sie den Dienst anschließend mit den Befehlen:
systemctl enable netzwerk-monitor.service
systemctl start  netzwerk-monitor.service


Der Dienst steuert den 10-Sekunden-Abstand nun automatisch über die Anweisung "RestartSec=10", sobald sich das Python-Skript nach einem Durchlauf beendet.

3. Die Konfigurationsdatei (netzwerk-hosts.csv)

Das Python-Skript liest alle zu überprüfenden Geräte aus der Datei "netzwerk-hosts.csv".
Diese Datei muss im selben Verzeichnis liegen (ggf. Pfade anpassen).

Der später folgende Editor würde zwar die Datei auch entsprechend korrekt anlegen,
es empfieht sich aber für Test eine kleine Testdatei selbst direkt zu erstellen:
nano  /var/www/html/netzwerk-hosts.csv
pluma /var/www/html/netzwerk-hosts.csv


Hier dann folgendes in der 'netzwerk-hosts.csv' einfügen:
ip,pc_name,username,mac
192.168.0.100,"TEST-Server","Server-USER",
192.168.0.100,"TEST-PC","TEST-USER",

Die IP-Adresse sollte auf einen lokalen erreichbaren PC/Netzwerkgerät verweisen der auf ping antwortet.
Die Datei sollte im UTF-8-Format (ohne BOM) mit Unix-Zeilenumbrüchen (LF) gespeichert werden (wird in der Regel automatisch korrekt erzeugt wenn man sie unter Linux hier so anlegt).

Zur Erklärung nochmal etwas genauer:
Die Datei ist wie folgt aufgebaut:
ip,pc_name,username,mac                                = 4 Spalten (IP-Adresse, PC-Name, User, MAC-Adresse)

,"Google Pixel","Handy Admin (Arno)",C1:A2:F3:F4:E5:E6 = Beachtet hier: Es wurde hier keine IP-Adresse, dafür die MAC Adresse angegeben (dies ist ein Gerät ohne feste IP-Adresse).

192.168.0.11,"Lenovo","PC Empfang/Herr Muster",        = ein normaler PC, die MAC-Adresse wird hier ausdrücklich nicht angegeben diese wird automatisch ermittelt
192.168.0.12,"Dell","PC Büro1/Frau Muster",            = auch ein normaler PC ohne angabe der MAC-Adresse die Spalte 4 nach dem Komma bleibt leer
192.168.0.40,"OKI","Drucker",                          = andere Netzwerkgeräte wie Drucker, Smartphones usw. werden alle identisch ohne MAC-Adresse angegeben

192.168.0.100,"DHCP","DHCP",                           = Der DHCP-Bereich muss komplett angegeben werden, das scheint am Anfang aufwändig.
192.168.0.101,"DHCP","DHCP",
192.168.0.102,"DHCP","DHCP",                           = Es erleichtert jedoch schnelle individuelle Anpassungen wie z. B. direkte Ausnahmen bei einzelnen festen IPs im DHCP-Bereich.
192.168.0.103,"Test-Server","Test-System",             = Hier z. B.ein Testsystem im DHCP-Bereich mit fester IP, man kann hier den Namen direkt schnell vergeben.
...
192.168.0.108,"DHCP","DHCP",
192.168.0.109,"DHCP","DHCP",
192.168.0.110,"DHCP","DHCP",


Für die korrekte Zuordnung gelten also folgende verbindliche Regeln:

3.1 Dateirechte festlegen

Um die Datei später mit dem Editor zu bearbeiten sind Schreibrechte für den Webserver auf der CSV-Datei notwendig:
chmod 666 /var/www/html/netzwerk-hosts.csv


4. Der grafische Web-Editor 'netzwerk-hosts-editor.php'

Die Verwaltung der einzelnen Netzwerkgeräte der CSV-Datei kann komfortabel über den Web-Editor 'netzwerk-hosts-editor.php' erfolgen.
Dieser bietet eine grafische Oberfläche im Browser, unterstützt eine Live-Suche sowie Sortierfunktionen und
erlaubt das Verschieben, Löschen oder Hinzufügen von Zeilen.
Beim Speichern übermittelt das Formular stets den gesamten Datenbestand, unabhängig von Filtereinstellungen.
Im Editor sind weiterhin einige Überprüfungen eingebaut die unter anderem die IPv4-Adressen überprüfen
oder die Angabe einer IP-Adresse und MAC-Adresse gleichzeitig verhindern (es ist nur eins notwendig).

Damit der lighttpd-Webserver PHP-Skripte verarbeiten kann, muss man nun noch die entsprechenden PHP-Komponenten installieren und im Webserver aktivieren:
apt update
apt install php-cgi php-fpm
lighty-enable-mod fastcgi fastcgi-php
systemctl restart lighttpd


Nun kann man den Web-Editor am einfachsten so erstellen:
nano  /var/www/html/netzwerk-hosts-editor.php
Pluma /var/www/html/netzwerk-hosts-editor.php


Hier nun den folgenden Code vom 'netzwerk-hosts-editor.php' einfügen:
<?php
header("Cache-Control: no-cache, no-store, must-revalidate"); // HTTP 1.1
header("Pragma: no-cache"); // HTTP 1.0
header("Expires: 0"); // Proxies

$csv_file = __DIR__ . '/netzwerk-hosts.csv';
$message = '';
$error = '';

$hosts = [];
if (file_exists($csv_file)) {
    if (($handle = fopen($csv_file, 'r')) !== false) {
        while (($row = fgetcsv($handle, 1000, ',')) !== false) {
            $hosts[] = $row;
        }
        fclose($handle);
        // BOM aus der allerersten Zelle entfernen, falls vorhanden
        if (!empty($hosts) && isset($hosts[0][0])) {
            $hosts[0][0] = preg_replace('/^\xEF\xBB\xBF/', '', $hosts[0][0]);
        }
    }
}

// Fallback für leere Datei
if (empty($hosts)) {
    $hosts[] = ['ip', 'pc_name', 'username', 'mac'];
}

$header = $hosts[0];
$data_rows = array_slice($hosts, 1);

function save_csv($header, $rows, $csv_file) {
    if (($fp = fopen($csv_file, 'w')) !== false) {
        fputcsv($fp, $header, ',');
        foreach ($rows as $row) {
            fputcsv($fp, $row, ',');
        }
        fclose($fp);
        return true;
    }
    return false;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $data_rows = [];
    if (isset($_POST['rows'])) {
        foreach ($_POST['rows'] as $row) {
            // Zeile wird nur ignoriert, wenn wirklich alle 4 Felder leer sind
            if (array_filter($row)) {
                $data_rows[] = [
                    trim($row[0] ?? ''),
                    trim($row[1] ?? ''),
                    trim($row[2] ?? ''),
                    trim($row[3] ?? '')
                ];
            }
        }
    }

    if (isset($_POST['delete']) && isset($_POST['index'])) {
        unset($data_rows[intval($_POST['index'])]);
        $data_rows = array_values($data_rows);
        save_csv($header, $data_rows, $csv_file);
        $message = "Eintrag gelöscht.";
    }

    if (isset($_POST['move']) && isset($_POST['index']) && isset($_POST['direction'])) {
        $index = intval($_POST['index']);
        $direction = $_POST['direction'];
        if ($direction === 'up' && $index > 0) {
            [$data_rows[$index - 1], $data_rows[$index]] = [$data_rows[$index], $data_rows[$index - 1]];
        } elseif ($direction === 'down' && $index < count($data_rows) - 1) {
            [$data_rows[$index], $data_rows[$index + 1]] = [$data_rows[$index + 1], $data_rows[$index]];
        }
        save_csv($header, $data_rows, $csv_file);
        $message = "Reihenfolge geändert.";
    }

    if (isset($_POST['add_row'])) {
        $data_rows[] = ["", "", "", ""];
        save_csv($header, $data_rows, $csv_file);
        $message = "Neue Zeile hinzugefügt.";
    }

    if (isset($_POST['save'])) {
        if (count($header) < 4) { $header[3] = 'mac'; }
        if (save_csv($header, $data_rows, $csv_file)) {
            $message = "Gespeichert.";
        } else {
            $error = "Fehler beim Schreiben der Datei.";
        }
    }
}
?>
<!DOCTYPE html><html lang="de"><head>
    <meta charset="UTF-8">
        <link rel="shortcut icon" type="image/x-icon" sizes="400x400" href="https://ctaas.de/box/netzwerk-monitor-edit.ico">
        <link rel="icon" type="image/x-icon" sizes="16x16 24x24 32x32 48x48 64x64 96x96 128x128 256x256" href="https://ctaas.de/box/netzwerk-monitor-edit.ico">
        <link rel="icon" type="image/png" sizes="400x400" href="https://ctaas.de/box/netzwerk-monitor-edit.png">
        <link rel="apple-touch-icon" href="https://ctaas.de/box/netzwerk-monitor-edit.png">
    <title>Hosts bearbeiten</title>
    <style>
        body { font-family: Arial, sans-serif; margin: 30px; background-color: #f5f5f5; }
        .msg { color: green; font-weight: bold; margin-bottom: 15px; }
        .error { color: red; font-weight: bold; margin-bottom: 15px; }
        .hint-box { background-color: #eef; border-left: 4px solid #0066cc; padding: 10px 15px; color: #555; font-size: 0.9em; margin-bottom: 20px; }
       
        .search-container { position: relative; display: inline-block; width: 100%; max-width: 400px; margin-bottom: 15px; }
        .search-box { width: 100%; padding: 8px 30px 8px 10px; box-sizing: border-box; border: 1px solid #ccc; }
        #clear-search-btn { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); cursor: pointer; color: #999; font-weight: bold; display: none; }
       
        table { border-collapse: collapse; width: 100%; background-color: white; }
        th, td { border: 1px solid #ccc; padding: 6px 10px; }
        th { background-color: #eee; text-align: left; }
        input[type="text"] { width: 100%; box-sizing: border-box; padding: 4px; }
        .btn { padding: 6px 12px; margin-right: 5px; cursor: pointer; border: 1px solid #888; background-color: #eaeaea; }
        .sort-buttons { display: flex; gap: 5px; }
       
        /* Neue Klasse für blockierte Felder */
        .blocked-field { background-color: #ffdce0 !important; }
       
        /* Neue Klasse für fehlerhafte IP-Adressen (roter Inset-Schatten) */
        .invalid-ip { box-shadow: inset 0 0 5px red !important; outline: none; }
    </style>
</head><body>
    <h2>Hosts bearbeiten</h2>
    <div class="hint-box">
        <strong>Hinweis:</strong> Die MAC-Adresse dient der namentlichen Zuordnung von DHCP-Geräten.
Damit die Ersetzung von "DHCP" durch den echten Namen funktioniert, muss die IP-Adresse des DHCP-Gerätes im Netzwerk-Scan-Skript (die Liste der anzupingenden IPs) explizit mit erfasst werden.
Ohne einen erfolgreichen Ping auf die IP kann das System die MAC-Adresse nicht aus dem ARP-Cache auslesen. Bei Geräten mit fester IP wird die MAC-Adresse automatisch live ermittelt.
    </div>

    <?php if ($message): ?><div class="msg"><?= htmlspecialchars($message) ?></div><?php endif; ?>
    <?php if ($error): ?><div class="error"><?= htmlspecialchars($error) ?></div><?php endif; ?>

    <div class="search-container">
        <input type="text" id="searchInput" class="search-box" placeholder="Tabelle durchsuchen (Name, IP, MAC, User...)">
        <span id="clear-search-btn">✖</span>
    </div>

    <form method="post" id="mainForm">
        <input type="hidden" name="action" id="formAction" value="save">
        <input type="hidden" name="index" id="formIndex" value="">
        <input type="hidden" name="direction" id="formDirection" value="">

        <table>
            <thead>
                <tr>
                    <th><?= htmlspecialchars($header[0] ?? 'ip') ?></th>
                    <th><?= htmlspecialchars($header[1] ?? 'pc_name') ?></th>
                    <th><?= htmlspecialchars($header[2] ?? 'username') ?></th>
                    <th><?= htmlspecialchars($header[3] ?? 'mac') ?></th>
                    <th>Aktion</th>
                </tr>
            </thead>
            <tbody>
                <?php foreach ($data_rows as $i => $row): ?>
                    <tr>
                        <td><input type="text" class="input-ip" name="rows[<?= $i ?>][]" value="<?= htmlspecialchars($row[0] ?? '') ?>"></td>
                        <td><input type="text" name="rows[<?= $i ?>][]" value="<?= htmlspecialchars($row[1] ?? '') ?>"></td>
                        <td><input type="text" name="rows[<?= $i ?>][]" value="<?= htmlspecialchars($row[2] ?? '') ?>"></td>
                        <td><input type="text" class="input-mac" name="rows[<?= $i ?>][]" value="<?= htmlspecialchars($row[3] ?? '') ?>"></td>
                        <td>
                            <div class="sort-buttons">
                                <button type="button" class="btn" onclick="moveRow(<?= $i ?>, 'up')">↑</button>
                                <button type="button" class="btn" onclick="moveRow(<?= $i ?>, 'down')">↓</button>
                                <button type="submit" name="delete" value="1" class="btn" onclick="document.getElementById('formIndex').value=<?= $i ?>;">Löschen</button>
                                </div>
                        </td>
                </tr>
            <?php endforeach; ?>
            </tbody>
        </table>

        <div style="margin-top:15px;">
            <button type="submit" name="save" class="btn">Speichern</button>
            <button type="button" class="btn" onclick="addRow()">Neue Zeile hinzufügen</button>
        </div>
    </form>

    <script>
        const sInput = document.getElementById('searchInput');
        const sBtn = document.getElementById('clear-search-btn');

        // Filter-Logik
        function applyFilter() {
            const term = sInput.value.toLowerCase();
            sBtn.style.display = term.length > 0 ? 'block' : 'none';
            sessionStorage.setItem('editorSearch', term);

            document.querySelectorAll('tbody tr').forEach(row => {
                const inputs = row.querySelectorAll('input[type="text"]');
                const isRowEmpty = Array.from(inputs).every(input => input.value.trim() === '');
                const rowText = Array.from(inputs).map(i => i.value.toLowerCase()).join(' ');
               
                // Zeile bleibt sichtbar wenn: Filter passt ODER Zeile ist komplett leer
                row.style.display = (rowText.includes(term) || isRowEmpty) ? '' : 'none';
            });
        }

        sInput.addEventListener('input', applyFilter);
        sBtn.addEventListener('click', () => { sInput.value = ''; applyFilter(); });

        // IP vs MAC Blockierungslogik und IP-Validierung
        function handleMutualExclusion() {
            document.querySelectorAll('tbody tr').forEach(row => {
                const ipInput = row.querySelector('.input-ip');
                const macInput = row.querySelector('.input-mac');

                if (!ipInput || !macInput) return;

                const updateState = () => {
                    const ipVal = ipInput.value.trim();
                    const hasIp = ipVal !== '';
                    const hasMac = macInput.value.trim() !== '';

                    // 1. Validierung der IP-Adresse (x.x.x.x wobei x 0-255 ist)
                    const ipRegex = /^(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}$/;
                    if (hasIp && !ipRegex.test(ipVal)) {
                        ipInput.classList.add('invalid-ip');
                    } else {
                        ipInput.classList.remove('invalid-ip');
                    }

                    // 2. Blockierungslogik
                    // Wenn IP befüllt und MAC leer ist -> MAC blockieren
                    if (hasIp && !hasMac) {
                        macInput.readOnly = true;
                        macInput.classList.add('blocked-field');
                        ipInput.readOnly = false;
                        ipInput.classList.remove('blocked-field');
                }
                    // Wenn MAC befüllt und IP leer ist -> IP blockieren
                    else if (!hasIp && hasMac) {
                        ipInput.readOnly = true;
                        ipInput.classList.add('blocked-field');
                        macInput.readOnly = false;
                        macInput.classList.remove('blocked-field');
                }
                    // Wenn beide leer oder versehentlich beide befüllt sind -> Beide freigeben
                    else {
                        ipInput.readOnly = false;
                        ipInput.classList.remove('blocked-field');
                        macInput.readOnly = false;
                        macInput.classList.remove('blocked-field');
            }
                };

                ipInput.addEventListener('input', updateState);
            macInput.addEventListener('input', updateState);
           
            // Initialer Status beim Laden der Seite
            updateState();
            });
        }

        // Suchbegriff bei Reload wiederherstellen und Blockierungslogik starten
        window.onload = () => {
            const savedSearch = sessionStorage.getItem('editorSearch');
            if (savedSearch) { sInput.value = savedSearch; applyFilter(); }
            handleMutualExclusion();
        };

        function addRow() {
            const form = document.getElementById('mainForm');
            const input = document.createElement('input');
            input.type = 'hidden'; input.name = 'add_row'; input.value = '1';
            form.appendChild(input); form.submit();
        }

        function moveRow(index, direction) {
            document.getElementById('formAction').value = 'move';
            document.getElementById('formIndex').value = index;
            document.getElementById('formDirection').value = direction;
            const input = document.createElement('input');
            input.type = 'hidden'; input.name = 'move'; input.value = '1';
            document.getElementById('mainForm').appendChild(input);
            document.getElementById('mainForm').submit();
        }
    </script>
</body></html>


5. User-Frontend 'netzwerk-monitor.htm'

Nun kommen wir zum wichtigsten dem User-Skript, dieses Skript ist für die visuelle Darstellung im Browser zuständig.
Es liest die vom Python-Backend erzeugte Datei 'netzwerk-scan.json' im Hintergrund asynchron per AJAX (Fetch API) alle 10 Sekunden ein.
Da die Aktualisierung direkt im Dokumentenmodell (DOM) der Seite stattfindet, flackert die Anzeige nicht und es erfolgt kein vollständiger Seiten-Reload.
Textmarkierungen, geöffnete Menüs oder die browserinterne Suche mit Strg+F werden also nicht unterbrochen.

Erstellen Sie die Datei im Webverzeichnis mit einem Editor:
nano  /var/www/html/netzwerk-monitor.htm
pluma /var/www/html/netzwerk-monitor.htm


Fügen sie hier folgenden 'netzwerk-monitor.htm' Code ein:
<!DOCTYPE html><html lang="de"><head>
    <meta charset="UTF-8">
        <link rel="shortcut icon" type="image/x-icon" sizes="400x400" href="https://ctaas.de/box/netzwerk-monitor.ico">
        <link rel="icon" type="image/x-icon" sizes="16x16 24x24 32x32 48x48 64x64 96x96 128x128 256x256" href="https://ctaas.de/box/netzwerk-monitor.ico">
        <link rel="icon" type="image/png" sizes="400x400" href="https://ctaas.de/box/netzwerk-monitor.png">
        <link rel="apple-touch-icon" href="https://ctaas.de/box/netzwerk-monitor.png">
    <title>PC Überwachung</title>
    <style>
        body { font-family: Cousine, sans-serif; margin: 10px; color:#222 }
        table { width: 100%; border-collapse: collapse; margin-top: 10px; }
        th, td { padding: 6px 12px; border: 1px solid #ddd; }
        th { cursor: pointer; background: #f2f2f2; }
       
        /* Zebra-Striping nur für sichtbare Zeilen (wegen Filter) */
        tbody tr:not([style*="display: none"]):nth-child(even) td:not(.online):not(.offline) { background: #f6f6f6; }
        tbody tr:hover td:not(.online):not(.offline) { background: #fff9c4 !important; }
       
        .online { background: #cfb !important; }
        .offline { background: #fbb !important; }
        th.asc::after { content: " ▲"; }
        th.desc::after { content: " ▼"; }
        .highlight td:not(:last-child) { background-color: #ffebcc !important; }
       
        .flex-container { display: flex; align-items: center; }
        .copy-btn {
            width: 1em;
            height: 1em;
            margin-left: 0.5em;
            cursor: pointer;
            fill: none;
            stroke: #666;
            stroke-width: 2;
            stroke-linecap: round;
            stroke-linejoin: round;
            transition: stroke 0.2s;
        }
        .copy-btn:hover {stroke: #000;}

        /* Styling für die Suche und den CSV-Export */
        .toolbar-container { display: flex; align-items: center; margin-bottom: 10px; }
        .search-container { position: relative; display: inline-block; width: 100%; max-width: 400px; }
        #search-input {
            width: 100%;
            padding: 6px 30px 6px 10px; /* Platz rechts für das X */
            box-sizing: border-box;
            border: 1px solid #ccc;
            border-radius: 4px;
            font-family: Cousine, sans-serif;
            font-size: 1rem;
        }
        #clear-search-btn {
            position: absolute;
            right: 10px;
            top: 50%;
            transform: translateY(-50%);
            cursor: pointer;
            color: #999;
            font-weight: bold;
            display: none; /* Standardmäßig ausgeblendet */
        }
        #clear-search-btn:hover { color: #333; }
        #export-csv-btn { width: 1.5em; height: 1.5em; margin-left: 10px; }
    </style>
</head><body>
    <h1 style="color:#444;font-size:19pt;margin-bottom:8px;">Netzwerküberwachung</h1>
    <p style="font-size: 0.8rem; color: #666; margin-top: 0; margin-bottom: 15px;" id="meta-info">Lade Daten...</p>
   
    <div class="toolbar-container">
        <div class="search-container">
            <input type="text" id="search-input" placeholder="Suche nach Name, IP, MAC, User...">
            <span id="clear-search-btn" title="Suche löschen">✖</span>
        </div>
        <svg id="export-csv-btn" class="copy-btn" viewBox="0 0 24 24" title="Tabelle als CSV kopieren">
            <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
            <polyline points="14 2 14 8 20 8"></polyline>
            <line x1="16" y1="13" x2="8" y2="13"></line>
            <line x1="16" y1="17" x2="8" y2="17"></line>
            <polyline points="10 9 9 9 8 9"></polyline>
        </svg>
    </div>

    <table id="monitor-table">
        <thead>
            <tr>
                <th data-sort="string">PC / Device</th>
                <th data-sort="string">Benutzername / Anmerkungen</th>
                <th data-sort="string">IP-Adresse</th>
                <th data-sort="string">MAC-Adresse</th>
                <th data-sort="string">Status</th>
            </tr>
        </thead>
        <tbody>
            </tbody>
    </table>

    <script>
        const table = document.getElementById('monitor-table');
        const tbody = table.tBodies[0];
        const headers = table.querySelectorAll('th');
        const searchInput = document.getElementById('search-input');
        const clearSearchBtn = document.getElementById('clear-search-btn');
        const exportCsvBtn = document.getElementById('export-csv-btn');
        let currentSort = JSON.parse(sessionStorage.getItem('sortState')) || { colIndex: 2, direction: 1 };
       
        // Globale Variablen für die Statusanzeige
        let lastUpdateTime = "...";
        let globalOnline = 0;
        let globalTotal = 0;

        // Text in die Zwischenablage kopieren (für IP und MAC) inkl. HTTP-Fallback
        function copyText(text) {
            if (navigator.clipboard && window.isSecureContext) {
                // Für HTTPS oder localhost
                navigator.clipboard.writeText(text).catch(err => {
                    console.error('Kopieren fehlgeschlagen', err);
                });
            } else {
                // Fallback für HTTP im lokalen Netzwerk
                const textArea = document.createElement("textarea");
                textArea.value = text;
                textArea.style.position = "fixed"; // Verhindert Scrollen zur Box
                textArea.style.left = "-999999px"; // Unsichtbar im Hintergrund
                document.body.appendChild(textArea);
                textArea.focus();
                textArea.select();
                try {
                    document.execCommand('copy');
                } catch (err) {
                    console.error('Fallback Kopieren fehlgeschlagen', err);
                }
                document.body.removeChild(textArea);
            }
        }

        // CSV Export-Funktion
        exportCsvBtn.addEventListener('click', () => {
            let csvContent = "";
           
            // Kopfzeilen extrahieren
            const headerCells = Array.from(headers).map(th => {
                return th.textContent.trim().replace(/;/g, ',');
            });
            csvContent += headerCells.join(';') + "\n";

            // Nur sichtbare Datenzeilen extrahieren
            const rows = Array.from(tbody.rows);
            rows.forEach(row => {
                if (row.style.display !== 'none') {
                    const cells = Array.from(row.cells).map(cell => {
                        // Zeilenumbrüche und überschüssige Leerzeichen entfernen, ; durch , ersetzen
                        let text = cell.textContent.trim().replace(/(\r\n|\n|\r)/gm, " ");
                        return text.replace(/;/g, ',');
                    });
                    csvContent += cells.join(';') + "\n";
                }
            });

            // In die Zwischenablage kopieren
            copyText(csvContent);
           
            // Kurzes optisches Feedback (Tooltip ändert sich)
            const originalTitle = exportCsvBtn.getAttribute('title');
            exportCsvBtn.setAttribute('title', 'Kopiert!');
            setTimeout(() => {
                exportCsvBtn.setAttribute('title', originalTitle);
            }, 2000);
        });

        // Hilfsfunktion: Aktualisiert den Text nur, wenn er sich geändert hat
        function updateCell(el, text) {
            if (el.textContent !== text) {
                el.textContent = text;
            }
        }

        // Filter-Logik
        function applyFilter() {
            const term = searchInput.value.toLowerCase();
            const rows = Array.from(tbody.rows);
           
            // X-Button ein/ausblenden
            clearSearchBtn.style.display = term.length > 0 ? 'block' : 'none';

            let filteredTotal = 0;
            let filteredOnline = 0;

            rows.forEach(row => {
                // Durchsucht den gesamten Text der Zeile (alle Spalten)
                if (row.textContent.toLowerCase().includes(term)) {
                    row.style.display = '';
                    filteredTotal++;
                   
                    // Zählen der Online-Geräte im Filter
                    const statusCell = row.querySelector('.col-status');
                    if (statusCell && statusCell.textContent.trim() === 'Online') filteredOnline++;
                } else {
                    row.style.display = 'none';
                }
            });

            // Veraltungs-Check (>10 Min = 600000ms). Wandelt Datum um in JS-lesbares Format
            let isStale = false;
            if (lastUpdateTime !== "...") { let ts = new Date(lastUpdateTime.replace(' Uhr', '').replace(' / ', ' ').replace(/-/g, '/')).getTime(); if (Date.now() - ts > 600000) isStale = true; }
            const staleMsg = isStale ? ` <span style="color:#f06; font-weight:normal;">| Hinweis: Server nicht erreichbar/Daten nicht aktuell.</span>` : "";

            // Metadaten-Anzeige aktualisieren
            const metaInfo = document.getElementById('meta-info');
            if (term.length > 0) metaInfo.innerHTML = `Letzte Aktualisierung: ${lastUpdateTime} | Status-online: <b>${globalOnline}/${globalTotal}</b> (Filter-online: <b>${filteredOnline}/${filteredTotal}</b>)${staleMsg}`;
            else metaInfo.innerHTML = `Letzte Aktualisierung: ${lastUpdateTime} | Status-online: <b>${globalOnline}/${globalTotal}</b>${staleMsg}`;
        }

        // Event-Listener für das Suchfeld und den X-Button
        searchInput.addEventListener('input', applyFilter);
        clearSearchBtn.addEventListener('click', () => {
            searchInput.value = '';
            applyFilter();
            searchInput.focus();
        });

        async function fetchAndUpdateData() {
            try {
                const response = await fetch('netzwerk-scan.json?t=' + new Date().getTime());
                const data = await response.json();
               
                // Globale Variablen für den Filterlauf aktualisieren
                lastUpdateTime = data.update_time;
                globalOnline = data.online_count;
                globalTotal = data.total_count;

                const currentIps = new Set(data.devices.map(d => d.ip));

                // 1. Entferne Zeilen, die im JSON nicht mehr existieren
                Array.from(tbody.rows).forEach(row => {
                    if (!currentIps.has(row.dataset.ip)) row.remove();
                });

                // 2. Aktualisiere oder erstelle Zeilen
                data.devices.forEach(device => {
                    let row = document.querySelector(`tr[data-ip="${device.ip}"]`);
                   
                    if (!row) {
                        row = tbody.insertRow();
                        row.dataset.ip = device.ip;
                        row.innerHTML = `
                            <td class="col-name"></td>
                            <td class="col-user"></td>
                            <td class="col-ip">
                                <div class="flex-container">
                                    <a target="_blank" class="ip-link"></a>
                                    <svg class="copy-btn copy-ip-btn" viewBox="0 0 24 24" title="IP kopieren">
                                        <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
                                        <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
                                    </svg>
                                </div>
                            </td>
                            <td class="col-mac-td">
                                <div class="flex-container">
                                    <span class="col-mac"></span>
                                    <svg class="copy-btn copy-mac-btn" viewBox="0 0 24 24" title="MAC kopieren" style="display:none;">
                                        <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
                                        <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
                                    </svg>
                                </div>
                            </td>
                            <td class="col-status"></td>
                        `;
                       
                        // Klick-Highlighting
                        row.addEventListener('click', (e) => {
                            if(e.target.closest('.copy-btn')) return; // Klick auf Copy ignorieren
                            document.querySelectorAll('.highlight').forEach(r => r.classList.remove('highlight'));
                            row.classList.add('highlight');
                            sessionStorage.setItem('selectedIp', device.ip);
                        });
                    }

                    // Zell-Inhalte sicher updaten
                    updateCell(row.querySelector('.col-name'), device.pc_name);
                    updateCell(row.querySelector('.col-user'), device.username);
                   
                    const link = row.querySelector('.ip-link');
                    updateCell(link, device.ip);
                    if(link.href !== `http://${device.ip}/`) link.href = `http://${device.ip}`;
                    row.querySelector('.copy-ip-btn').onclick = () => copyText(device.ip);
                   
                    // MAC-Adresse und deren Copy-Button updaten
                    const macSpan = row.querySelector('.col-mac');
                    const macBtn = row.querySelector('.copy-mac-btn');
                    updateCell(macSpan, device.mac);
                   
                    if (device.mac) {
                        macBtn.style.display = 'block';
                        macBtn.onclick = () => copyText(device.mac);
                    } else {
                        macBtn.style.display = 'none';
                    }
                   
                    const statusCell = row.querySelector('.col-status');
                    updateCell(statusCell, device.status);
                    const statusClass = device.status === 'Online' ? 'online' : 'offline';
                    if (!statusCell.classList.contains(statusClass)) statusCell.className = `col-status ${statusClass}`;

                    // Gespeichertes Highlight wiederherstellen
                    if (sessionStorage.getItem('selectedIp') === device.ip) row.classList.add('highlight');
                });

                // Tabellensortierung direkt nach Daten-Update anwenden
                if (currentSort.colIndex !== null) applySort(currentSort.colIndex, currentSort.direction);
               
                // Filter nach Daten-Update (Reload) erneut anwenden, damit Suchergebnisse und Statuszahlen stabil bleiben
                applyFilter();

            } catch (error) {
                console.error('Fehler beim Laden der JSON:', error);
                // Trigger applyFilter um ggf. Offline-Warnung anzuzeigen, wenn Server down ist
                applyFilter();
            }
        }

        // Sortierlogik
        function applySort(colIndex, direction) {
            const rows = Array.from(tbody.rows);
            const sortType = headers[colIndex].getAttribute('data-sort');
           
            rows.sort((a, b) => {
                let aVal = a.cells[colIndex].textContent.trim();
                let bVal = b.cells[colIndex].textContent.trim();
               
                if (colIndex === 2) { // IP Adresse
                    const ipToTuple = ip => ip.split('.').map(Number);
                    const aParts = ipToTuple(aVal);
                    const bParts = ipToTuple(bVal);
                    for (let i = 0; i < 4; i++) {
                        if (aParts[i] < bParts[i]) return -direction;
                        if (aParts[i] > bParts[i]) return direction;
                    }
                    return 0;
                } else if (sortType === 'string') {
                    return direction * aVal.localeCompare(bVal, 'de');
                }
                return direction * (aVal - bVal);
            });
           
            tbody.append(...rows);
           
            headers.forEach((th, i) => {
                th.classList.remove('asc', 'desc');
                if (i === colIndex) th.classList.add(direction === 1 ? 'asc' : 'desc');
            });
        }

        // Event-Listener für Spaltenköpfe
        headers.forEach((header, i) => {
            header.addEventListener('click', () => {
                let direction = 1;
                if (currentSort.colIndex === i) direction = -currentSort.direction;
                currentSort = { colIndex: i, direction: direction };
                sessionStorage.setItem('sortState', JSON.stringify(currentSort));
                applySort(i, direction);
            });
        });

        // Initialer Start und Intervall
        fetchAndUpdateData();
        setInterval(fetchAndUpdateData, 10000);
    </script>
</body></html>


Das Frontend bietet folgende Funktionen:

6. Der Zugriff auf den Live Netzwerkscanner & Editor im Netzwerk erflogt einfach über einen beliebigen Webbrowser

Nachdem die Konfiguration abgeschlossen ist und die Ausgabedatei im Verzeichnis abgelegt wurde,

ist der Netzwerk-Monitor wie folgt erreichbar:

http://127.0.0.1 (localhost) - direkt am Server
http://192.168.x.x/netzwerk-monitor.htm - von jedem anderen Arbeitsplatzrechner im selben lokalen Netzwerk (LAN)
Die Seite aktualisiert sich alle 10 Sekunden. Sie spiegelt somit praktisch einen Live Status aller Systeme im Netzwerk wieder.

Bzw. den Web-Editor kann analog dazu über folgende URL aufgerufen:
http://127.0.0.1/netzwerk-hosts-editor.php - vom Server
http://192.168.x.x/netzwerk-hosts-editor.php - aus dem LAN Server

7. Anmerkungen

IP-Adressen, E-Mailadressen, Namen u. ä. wurden für die Dokumentation geändert, hacken ist also zwecklos.
Die Nutzung der Anleitung erfolgt auf eigene Gefahr, für jegliche Schäden wird keine Garantie/Haftung übernommen.
Die Dokumentation entstand aus verschiedenen Tests, sowie produktiven Installationen und stellt eine Zusammenfassung wichtiger und empfohlener Schritte dar.
Bevor sie eventuell Fragen stellen bitte ich sie die Dokumentation komplett zu lesen.
Hinweise auf Fehler, Anregungen, Danksagungen oder ähnliches sind immer willkommen.

Wichtige Ergänzungen:


-- Ende --

Diese Seite ist neutral und unabhängig.
Alle Anleitungen stehen zu 100 %
kostenlos zur Verfügung.
Die Finanzierung erfolgt teilweise
über Werbung und Partnerprovisionen.
Danke wenn Du mich dabei unterstützt.

Alle Infos zum Bedanken findest Du hier.

Besuche Amazon vor Deinem nächsten
Einkauf über diesen Danke Affiliate Link.


Hier kannst Du mir mit PayPal Danken.
Du kannst den Betrag auch anpassen.

Gefällt Dir meine Anleitung?
Hier kannst Du mich Bewerten.
5-Stars