dolibarr.netdiag/lib/netdiag.lib.php
Eduard Wisch 0f37cae9e5 Drei Falschaussagen im Kundendokument, alle aus Altdaten
"0 DHCP-Server (!)" in Rot: In der Produktionsdatenbank stehen drei
ausgelieferte Messungen des entfernten Werkzeugs dhcpcheck mit Ergebnis
{"count":0,"server":[],"hinweis":""} und Status 2. Bei Kabelverbindung gibt
Android die DHCP-Angaben aber grundsaetzlich nicht heraus (nur DhcpInfo, nur
WLAN, deprecated) - gemessen wurde also nichts, "0 gefunden" war der
Rueckgabewert fuer "nicht ermittelbar". netdiagAltlastKorrektur() zeigt diese
Messungen jetzt als "nicht messbar" mit Erklaerung, in PDF und
Technikeransicht. Rohdaten bleiben unangetastet, korrigiert wird nur die
Darstellung - und die wird bei jedem Abruf neu erzeugt. Die Ergebniszahlen
werden dabei unterdrueckt: "Gefundene Geraete: 0" widerspraeche der Aussage
direkt darueber.

"Lease-Dauer: 864000 s s": aeltere App-Fassungen haben die Einheit in den Wert
geschrieben, die Feldtabelle haengt sie erneut an (Prod-Messung #42).

Adress- und Lease-Felder mit dem Wert 0 erscheinen jetzt als "nicht
ermittelbar" statt als "0" - das war nie eine Messung, sondern das
"nichts ermittelt" der alten Android-API.

Neu in der Whitelist: dhcpQuelle (system = LinkProperties ab Android 11 fuer
jeden Anschlusstyp, wlan = alter Weg nur fuer WLAN). Damit ist klaerbar, ob
eine fehlende DHCP-Angabe an der Leitung lag oder an der Android-Version.

Geprueft gegen die lokale Testinstanz mit einer Kopie der echten
Prod-Messungen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:11:07 +02:00

697 lines
26 KiB
PHP

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* \file htdocs/custom/netdiag/lib/netdiag.lib.php
* \ingroup netdiag
* \brief Hilfsfunktionen für das Modul NetDiag
*/
/**
* Tabs für die Admin-Seiten des Moduls vorbereiten
*
* @return array<int,array<int,string>> Tab-Array
*/
function netdiagAdminPrepareHead()
{
global $langs, $conf;
$langs->load("netdiag@netdiag");
$h = 0;
$head = array();
$head[$h][0] = dol_buildpath("/netdiag/admin/setup.php", 1);
$head[$h][1] = $langs->trans("Settings");
$head[$h][2] = 'settings';
$h++;
$head[$h][0] = dol_buildpath("/netdiag/admin/about.php", 1);
$head[$h][1] = $langs->trans("About");
$head[$h][2] = 'about';
$h++;
complete_head_from_modules($conf, $langs, null, $head, $h, 'netdiag@netdiag');
complete_head_from_modules($conf, $langs, null, $head, $h, 'netdiag@netdiag', 'remove');
return $head;
}
/**
* Tabs für die Detailansicht eines Diagnose-Protokolls vorbereiten
*
* @param NetDiagProtocol $object Protokoll-Objekt
* @return array<int,array<int,string>> Tab-Array
*/
function netdiagProtocolPrepareHead($object)
{
global $langs, $conf;
$langs->load("netdiag@netdiag");
$h = 0;
$head = array();
$head[$h][0] = dol_buildpath("/netdiag/netdiagprotocol_card.php", 1).'?id='.$object->id;
$head[$h][1] = $langs->trans("NetDiagProtocol");
$head[$h][2] = 'card';
$h++;
complete_head_from_modules($conf, $langs, $object, $head, $h, 'netdiagprotocol@netdiag');
complete_head_from_modules($conf, $langs, $object, $head, $h, 'netdiagprotocol@netdiag', 'remove');
return $head;
}
/**
* Ausgabeverzeichnis des Moduls ermitteln (für PDF-Dokumente)
*
* @return string Absoluter Pfad zum Dokumentenverzeichnis
*/
function netdiagGetOutputDir()
{
global $conf;
if (!empty($conf->netdiag->dir_output)) {
return $conf->netdiag->dir_output;
}
return DOL_DATA_ROOT.'/netdiag';
}
/**
* Ein Mess-Ergebnis (JSON) lesbar als HTML aufbereiten.
*
* Generisch: rendert flache Schlüssel/Wert-Paare und einfache Listen,
* damit auch künftige Tools ohne Code-Änderung dargestellt werden. Jeder Wert
* wird auf 200 Zeichen gekürzt (`dol_trunc`) — für Skalare unschädlich, aber
* ein Dauertest mit vielen Ausfällen/Minuten-Buckets würde dabei mitten im
* Satz abgeschnitten. Für `$tool === 'stresstest'` deshalb eigener, nicht
* gekürzter Zweig (siehe netdiagFormatStressTest) — dieselbe Notlösung wie
* netdiagPdfStressTest() im PDF-Generator.
*
* @param string $json JSON-String des Ergebnisses
* @param string $tool Werkzeug-ID der Messung (optional, für Sonderfälle)
* @return string HTML-Schnipsel
*/
function netdiagFormatResult($json, $tool = '')
{
if (empty($json)) {
return '<span class="opacitymedium">-</span>';
}
$data = json_decode($json, true);
if ($data === null) {
return dol_escape_htmltag(dol_trunc($json, 120));
}
if (!is_array($data)) {
return dol_escape_htmltag((string) $data);
}
if ($tool === 'stresstest') {
return netdiagFormatStressTest($data);
}
if ($tool === 'wifikanal') {
return netdiagFormatWifiKanal($data);
}
$out = '<div class="netdiag-result">';
foreach ($data as $key => $val) {
$label = dol_escape_htmltag(ucfirst((string) $key));
if (is_array($val)) {
$flat = array();
foreach ($val as $item) {
$flat[] = is_array($item) ? json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string) $item;
}
$valstr = implode(', ', $flat);
} elseif (is_bool($val)) {
$valstr = $val ? 'ja' : 'nein';
} else {
$valstr = (string) $val;
}
$out .= '<span class="netdiag-kv"><strong>'.$label.':</strong> '.dol_escape_htmltag(dol_trunc($valstr, 200)).'</span> ';
}
$out .= '</div>';
return $out;
}
/**
* Dauer-/Stresstest-Ergebnis strukturiert als HTML aufbereiten (Kennzahlen +
* Ausfallliste, ungekürzt) statt es wie ein generisches Werkzeug zu
* behandeln — sonst schneidet dol_trunc(...,200) die Ausfallliste eines
* längeren Laufs mitten im Satz ab.
*
* @param array<string,mixed> $data bereits dekodiertes result-JSON
* @return string HTML-Schnipsel
*/
function netdiagFormatStressTest($data)
{
if (!empty($data['hinweis']) || !empty($data['fehler'])) {
return '<span class="opacitymedium">'.dol_escape_htmltag((string) ($data['hinweis'] ?? $data['fehler'])).'</span>';
}
$sum = array();
if (isset($data['host'])) {
$sum[] = 'Ziel: '.$data['host'];
}
if (isset($data['dauerSekunden'])) {
$sum[] = 'Dauer: '.round($data['dauerSekunden'] / 60).' min';
}
if (isset($data['intervallSek'])) {
$sum[] = 'Takt: '.$data['intervallSek'].' s';
}
if (isset($data['gesendet']) && isset($data['empfangen'])) {
$sum[] = 'Proben: '.$data['empfangen'].'/'.$data['gesendet'];
}
if (isset($data['verlustProzent'])) {
$sum[] = 'Verlust: '.$data['verlustProzent'].' %';
}
if (isset($data['avgMs']) && $data['avgMs'] !== null) {
$sum[] = 'Ø '.$data['avgMs'].' ms';
}
if (isset($data['minMs']) && isset($data['maxMs']) && $data['minMs'] !== null) {
$sum[] = 'Min/Max '.$data['minMs'].'/'.$data['maxMs'].' ms';
}
if (isset($data['p95Ms']) && $data['p95Ms'] !== null) {
$sum[] = 'p95 '.$data['p95Ms'].' ms';
}
if (isset($data['laengsterAusfallSek']) && $data['laengsterAusfallSek'] !== null) {
$sum[] = 'längster Ausfall '.$data['laengsterAusfallSek'].' s';
}
$out = '<div class="netdiag-result"><div class="netdiag-kv">'.dol_escape_htmltag(implode(' | ', $sum)).'</div>';
$ausfaelle = (isset($data['ausfaelle']) && is_array($data['ausfaelle'])) ? $data['ausfaelle'] : array();
if (empty($ausfaelle)) {
$out .= '<div class="opacitymedium">Kein Ausfall während des Laufs.</div>';
} else {
$out .= '<div><strong>Ausfälle ('.count($ausfaelle).'):</strong></div><ul class="netdiag-outages">';
foreach ($ausfaelle as $line) {
$out .= '<li>'.dol_escape_htmltag((string) $line).'</li>';
}
$out .= '</ul>';
}
$out .= '</div>';
return $out;
}
/**
* WLAN-Kanal-Momentaufnahme strukturiert als HTML aufbereiten.
*
* Wie beim Dauertest ein eigener Zweig statt der generischen Darstellung:
* `netze` ist eine Liste von Objekten und würde dort als JSON-Text mit
* dol_trunc(…,200) mitten im Satz abgeschnitten.
*
* @param array<string,mixed> $data bereits dekodiertes result-JSON
* @return string HTML-Schnipsel
*/
function netdiagFormatWifiKanal($data)
{
$out = '<div class="netdiag-result">';
// Aus dem Demomodus der App: erfundene Netze zum Ansehen/Vorführen. Muss
// deutlich sichtbar bleiben, sonst wird eine Demo später für eine echte
// Messung am Kundenstandort gehalten.
if (!empty($data['demodaten'])) {
$out .= '<div class="netdiag-kv"><strong style="color:#b45309">'
.dol_escape_htmltag('DEMODATEN — keine echte Messung').'</strong></div>';
}
$sum = array();
if (isset($data['anzahlNetze'])) {
$sum[] = $data['anzahlNetze'].' Netze sichtbar';
}
if (!empty($data['eigenesNetz']) && is_array($data['eigenesNetz'])) {
$e = $data['eigenesNetz'];
$teil = 'Eigenes Netz: '.($e['ssid'] ?? '?').' — Kanal '.($e['kanal'] ?? '?');
if (!empty($e['band'])) {
$teil .= ' ('.$e['band'].')';
}
if (isset($e['rssi'])) {
$teil .= ', '.$e['rssi'].' dBm';
}
if (!empty($e['bewertung'])) {
$teil .= ' — '.$e['bewertung'];
}
$sum[] = $teil;
}
if (!empty($data['empfehlung24GHz']) && is_array($data['empfehlung24GHz'])) {
$best = reset($data['empfehlung24GHz']);
if (is_array($best) && isset($best['kanal'])) {
$sum[] = 'Störungsärmster 2,4-GHz-Kanal: '.$best['kanal'];
}
}
if (!empty($sum)) {
$out .= '<div class="netdiag-kv">'.dol_escape_htmltag(implode(' | ', $sum)).'</div>';
}
if (!empty($data['warnungen']) && is_array($data['warnungen'])) {
$out .= '<div><strong>Hinweise:</strong></div><ul class="netdiag-outages">';
foreach ($data['warnungen'] as $w) {
$out .= '<li>'.dol_escape_htmltag((string) $w).'</li>';
}
$out .= '</ul>';
}
$netze = (isset($data['netze']) && is_array($data['netze'])) ? $data['netze'] : array();
if (!empty($netze)) {
$out .= '<table class="noborder centpercent"><tr class="liste_titre">';
$out .= '<th>SSID</th><th class="center">Kanal</th><th>Band</th>';
$out .= '<th class="center">Breite</th><th>Standard</th><th>Sicherheit</th><th class="right">Pegel</th>';
$out .= '</tr>';
foreach ($netze as $n) {
if (!is_array($n)) {
continue;
}
$out .= '<tr class="oddeven">';
$out .= '<td>'.dol_escape_htmltag((string) ($n['ssid'] ?? '')).'</td>';
$out .= '<td class="center">'.dol_escape_htmltag((string) ($n['kanal'] ?? '')).'</td>';
$out .= '<td>'.dol_escape_htmltag((string) ($n['band'] ?? '')).'</td>';
$out .= '<td class="center">'.(isset($n['breiteMhz']) && $n['breiteMhz'] !== null ? dol_escape_htmltag($n['breiteMhz'].' MHz') : '-').'</td>';
$out .= '<td>'.dol_escape_htmltag((string) ($n['standard'] ?? '-')).'</td>';
$out .= '<td>'.dol_escape_htmltag((string) ($n['sicherheit'] ?? '-')).'</td>';
$out .= '<td class="right">'.dol_escape_htmltag((string) ($n['rssi'] ?? '')).' dBm</td>';
$out .= '</tr>';
}
$out .= '</table>';
}
$out .= '</div>';
return $out;
}
/**
* Klartext-Bezeichnung und Einheit für einen Ergebnis-Schlüssel.
*
* Vorher wurde der rohe JSON-Schlüssel nur großgeschrieben ausgegeben — das
* Kundendokument las sich wie ein Datenbank-Auszug: „VerlustProzent: 0 |
* MinMs: 4.4 | ArpAvailable: nein | MdnsOk: ja". Kennzahlen standen ohne
* Einheit da („UptimeSek: 8123456"), und interne Messwerkzeug-Details, die den
* Kunden nichts angehen, standen mitten im Dokument.
*
* Bewusst eine WHITELIST: was hier nicht steht, erscheint nicht im
* Kundendokument. Ein neues Werkzeug muss also einmalig hier eingetragen
* werden — dafür kann nie versehentlich ein internes Feld durchrutschen.
* Die Technikeransicht in Dolibarr zeigt weiterhin alle Felder.
*
* @return array<string,array{0:string,1:string}> Schlüssel => [Bezeichnung, Einheit]
*/
function netdiagKundenfelder()
{
return array(
// gemeinsam
'host' => array('Ziel', ''),
'subnet' => array('Netzbereich', ''),
'count' => array('Gefundene Geräte', ''),
// Ping / Laufzeit
'gesendet' => array('Gesendet', 'Pakete'),
'empfangen' => array('Empfangen', 'Pakete'),
'verlustProzent' => array('Paketverlust', '%'),
'minMs' => array('Kürzeste Antwortzeit', 'ms'),
'avgMs' => array('Mittlere Antwortzeit', 'ms'),
'medianMs' => array('Typische Antwortzeit', 'ms'),
'maxMs' => array('Längste Antwortzeit', 'ms'),
'p95Ms' => array('Antwortzeit (95 %)', 'ms'),
'jitterMs' => array('Schwankung', 'ms'),
'verfahren' => array('Messverfahren', ''),
// Durchsatz
'downMbps' => array('Download', 'Mbit/s'),
'upMbps' => array('Upload', 'Mbit/s'),
'mbitProSekunde' => array('Durchsatz', 'Mbit/s'),
// Dauertest
'dauerSekunden' => array('Messdauer', 's'),
'intervallSek' => array('Messabstand', 's'),
'laengsterAusfallSek' => array('Längster Ausfall', 's'),
// Geräte-Monitor (Dauerüberwachung). Das ist der Beleg für „das Netz
// war über X Stunden stabil" bzw. für die häufigste Reklamation
// („die Kamera fällt ständig aus") — die Felder gehören deshalb
// vollständig ins Kundendokument, nicht nur die Ampel.
'geraeteAnzahl' => array('Überwachte Geräte', ''),
'verfuegbarkeitProzent' => array('Verfügbarkeit (schlechtestes Gerät)', '%'),
// „Ausfallzeit" allein war irreführend: der Wert ist der des schlechtesten
// Geräts, nicht die Summe über alle. Bei zehn überwachten Geräten liest
// sich „Ausfallzeit: 8 min" sonst wie eine Gesamtaussage.
'ausfallzeitSek' => array('Ausfallzeit (schlechtestes Gerät)', 's'),
'aussetzer' => array('Aussetzer', ''),
'geraete' => array('Je Gerät', ''),
'ausfaelle' => array('Einzelne Ausfälle', ''),
// Internet-Kette (Anschluss → Gateway → Namensauflösung → HTTPS).
// 'fehlerglied' ist die eigentliche Aussage des Werkzeugs — ohne diesen
// Eintrag stünde im Kundendokument nur die Ampel.
'fehlerglied' => array('Fehlerglied', ''),
'stufen' => array('Geprüfte Stufen', ''),
'systemUrteil' => array('Urteil des Betriebssystems', ''),
// Kommen aus dem LinkInfo-Objekt und damit auch im IP-Test (Dose) vor
'validated' => array('Internetverbindung bestätigt', ''),
'captivePortal' => array('Anmeldeseite erkannt', ''),
// Netz/DHCP
'server' => array('DHCP-Server', ''),
'lease' => array('Lease-Dauer', 's'),
'gateway' => array('Gateway', ''),
'dns' => array('DNS-Server', ''),
// SNMP / Switch
'sysDescr' => array('Gerätebeschreibung', ''),
'uptimeSek' => array('Betriebszeit', 's'),
// Traceroute
'reachedTarget' => array('Ziel erreicht', ''),
// WLAN
'anzahlNetze' => array('Sichtbare WLAN-Netze', ''),
// --- Schlüssel, welche die App TATSÄCHLICH liefert ---
// Sie weichen teils von den obigen ab (deutsche Namen, andere Schreibung).
// Ohne diese Einträge verwirft die Whitelist sie stillschweigend und im
// Kundendokument steht bei Portscan, Traceroute, iPerf, SNMP und
// IP-Konflikt nur noch die Ampel — ohne einen einzigen Messwert. Genau
// das ist beim ersten Bau dieser Tabelle passiert, weil sie nur gegen
// selbst erzeugte Testdaten geprüft wurde statt gegen die echten
// Werkzeuge. Beim Anlegen eines Werkzeugs hier eintragen.
'ziel' => array('Ziel', ''),
'zielErreicht' => array('Ziel erreicht', ''),
'hops' => array('Zwischenstellen', ''),
'gegenstelle' => array('Gegenstelle', ''),
'downloadMbps' => array('Download', 'Mbit/s'),
'uploadMbps' => array('Upload', 'Mbit/s'),
'ip' => array('Gerät', ''),
'scanned' => array('Geprüfte Ports', ''),
'open' => array('Offene Ports', ''),
'geprueft' => array('Geprüfte Adressen', ''),
'runden' => array('Messrunden', ''),
'konflikte' => array('IP-Konflikte', ''),
'port' => array('Switch-Port', ''),
'linkSpeed' => array('Verbindungsgeschwindigkeit', ''),
'eingangsFehler' => array('Fehler eingehend', ''),
'ausgangsFehler' => array('Fehler ausgehend', ''),
// IP-Test (Dose): die Seite reicht das LinkInfo-Objekt durch
'ipv4' => array('IP-Adresse', ''),
'prefixLength' => array('Präfixlänge', ''),
'dhcpServer' => array('DHCP-Server', ''),
// 'system' = vom Betriebssystem gemeldet (gilt für jeden Anschlusstyp),
// 'wlan' = alter WLAN-Weg. Macht einen fehlenden Wert einordenbar.
'dhcpQuelle' => array('Quelle der DHCP-Angabe', ''),
'leaseSec' => array('Lease-Dauer', 's'),
'linkSpeedMbps' => array('Verbindungsgeschwindigkeit', 'Mbit/s'),
'rxLinkSpeedMbps' => array('Empfangsgeschwindigkeit', 'Mbit/s'),
'ssid' => array('WLAN-Name', ''),
'raum' => array('Raum / Dose', ''),
'iface' => array('Schnittstelle', ''),
// Anschlussart. ACHTUNG: hier stand bis 16.08.2026 'typ' — ein Schlüssel,
// den kein einziges Werkzeug erzeugt. Die App liefert das LinkInfo-Objekt
// durch, und dort heißt das Feld 'type' (app/src/lib/types.ts). Folge:
// im Kundendokument fehlte ausgerechnet beim IP-Test an der Dose die
// Angabe, ob per LAN oder WLAN gemessen wurde — bei einem Abnahmebeleg
// die halbe Aussage. Rohwerte sind englisch und werden in
// netdiagKundentext() übersetzt.
'type' => array('Anschlussart', ''),
// Empfangspegel: bei einer WLAN-Dose IST das der Messwert. Wurde bisher
// verworfen, weil nicht in der Liste.
'rssi' => array('Empfangspegel', 'dBm'),
'neu' => array('Neu hinzugekommen', ''),
'nichtMehrErreichbar' => array('Nicht mehr erreichbar', ''),
'demodaten' => array('DEMODATEN — keine echte Messung', ''),
// Bewusst NICHT aufgenommen (Messgeräte-Interna, für den Kunden ohne
// Aussage): 'bssid', 'isUsbEthernet', 'isDefault', 'dns'.
// Fehler/Hinweise — immer zeigen, sonst bliebe eine gelbe oder rote Ampel
// im Kundendokument ohne jede Erklärung stehen. Der Text läuft durch
// netdiagKundentext() und wird dort von Messgeräte-Interna befreit.
'fehler' => array('Fehler', ''),
// 'error' ist die englische Schreibweise desselben Feldes. Die App
// vereinheitlicht auf 'fehler', aber in bereits ausgelieferten
// Protokollen steht 'error' — und Altdaten lassen sich nicht ändern.
// Ohne diesen Eintrag stünde dort eine rote Ampel ohne einen Buchstaben
// Erklärung.
'error' => array('Fehler', ''),
'hinweis' => array('Hinweis', ''),
'kundenhinweis' => array('Hinweis', ''),
);
}
/**
* Bekannte Falschaussagen aus Altmessungen bei der Anzeige geraderücken.
*
* Betrifft das entfernte Werkzeug `dhcpcheck`. In der Produktionsdatenbank
* stehen drei ausgelieferte Messungen mit dem Label „0 DHCP-Server (!)",
* Ergebnis `{"count":0,"server":[],"hinweis":""}` und **Status 2 (rot)** — eine
* Aussage über das Kundennetz, welche die Messung nie hergab: bei einer
* Kabelverbindung gibt Android die DHCP-Lease grundsätzlich nicht heraus (sie
* steckt nur im deprecated `DhcpInfo` und nur für WLAN). Gemessen wurde also
* nichts; „0 gefunden" ist der Rückgabewert für „nicht ermittelbar".
*
* Korrigiert wird bei der **Anzeige**, nicht in der Datenbank: die gespeicherte
* Messung bleibt unangetastet (Rohdaten sind Beleg), aber jedes neu erzeugte
* Kundendokument zeigt statt der roten Ampel ein ehrliches „nicht messbar".
*
* @param string $tool Werkzeug-ID der Messung
* @param int $status gespeicherter Status
* @param string $label gespeichertes Label
* @param string $json gespeichertes Ergebnis-JSON
* @return array{status:int,label:string,hinweis:string} korrigierte Anzeige
*/
function netdiagAltlastKorrektur($tool, $status, $label, $json)
{
$out = array('status' => (int) $status, 'label' => (string) $label, 'hinweis' => '');
if ($tool !== 'dhcpcheck') {
return $out;
}
$data = json_decode((string) $json, true);
if (!is_array($data)) {
return $out;
}
$leer = (isset($data['count']) && (int) $data['count'] === 0)
&& (!isset($data['server']) || $data['server'] === array() || $data['server'] === '');
if (!$leer) {
return $out;
}
$out['status'] = 3; // nicht messbar
$out['label'] = 'DHCP-Server nicht ermittelbar';
$out['hinweis'] = 'Diese Messung stammt aus einer früheren App-Fassung. Bei einer '
.'Kabelverbindung gibt Android die DHCP-Angaben nicht heraus — es wurde also nichts '
.'gemessen. Die frühere Darstellung („0 DHCP-Server") las sich wie ein Befund über '
.'das Kundennetz und war keiner.';
return $out;
}
/**
* Sekundenwert lesbar machen (aus „8123456" wird „94 Tage 0 Std").
*
* @param int|float $sek Sekunden
* @return string lesbare Dauer
*/
function netdiagDauerLesbar($sek)
{
$sek = (int) $sek;
if ($sek < 60) {
return $sek.' s';
}
if ($sek < 3600) {
return round($sek / 60).' min';
}
if ($sek < 86400) {
$std = floor($sek / 3600);
$min = round(($sek % 3600) / 60);
// Volle Stunde nicht als „2 Std 0 min" ausgeben
return $std.' Std'.($min > 0 ? ' '.$min.' min' : '');
}
// (int) nicht weglassen: floor() liefert einen Float, und 1.0 === 1 ist in
// PHP false — die Einzahl unten hätte sonst nie gegriffen.
$tage = (int) floor($sek / 86400);
$std = (int) round(($sek % 86400) / 3600);
// Einzahl/Mehrzahl und die glatte Angabe: eine DHCP-Lease von genau einem
// Tag las sich vorher als „1 Tage 0 Std".
return $tage.($tage === 1 ? ' Tag' : ' Tage').($std > 0 ? ' '.$std.' Std' : '');
}
/**
* Ein Ergebnisfeld für das Kundendokument aufbereiten.
*
* @param string $key Schlüssel aus dem Ergebnis-JSON
* @param mixed $val Wert
* @return string|null „Bezeichnung: Wert Einheit" oder null, wenn das Feld
* nicht ins Kundendokument gehört
*/
function netdiagFeldFuerKunde($key, $val)
{
$felder = netdiagKundenfelder();
if (!isset($felder[$key])) {
return null; // internes Feld — bewusst nicht im Kundendokument
}
list($label, $einheit) = $felder[$key];
if (is_array($val)) {
$flat = array();
foreach ($val as $item) {
$flat[] = is_array($item) ? json_encode($item, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) : (string) $item;
}
$val = implode(', ', $flat);
// Leeres Array = das gute Ergebnis: kein offener Port, kein IP-Konflikt,
// keine Veränderung seit dem letzten Scan. Bis 16.08.2026 stand hier im
// Kundendokument „Offene Ports: " ohne Wert — die Leerprüfung weiter
// unten ist ein elseif und wurde nach diesem Zweig nie erreicht.
// „keine" statt Weglassen, weil die Zeile sonst kommentarlos
// verschwindet und der Kunde nicht unterscheiden kann, ob nichts
// gefunden oder nichts geprüft wurde.
if ($val === '') {
$val = 'keine';
}
} elseif (is_bool($val)) {
$val = $val ? 'ja' : 'nein';
} elseif ($val === null || $val === '') {
return null; // nicht ermittelt — lieber weglassen als „: " drucken
}
/*
* Adressfelder mit dem Wert 0 bzw. 0.0.0.0 sind KEINE Messung, sondern der
* Rückgabewert „nichts ermittelt" der alten Android-API. In ausgelieferten
* Protokollen steht deshalb „DHCP-Server: 0" — eine Angabe, die es so im
* Kundennetz nie gab, und bei einer Kabelverbindung schlicht der Umstand,
* dass Android die Lease dort gar nicht herausgibt (nur WLAN, deprecated).
* Rückwirkend wirksam: Protokoll-PDFs werden bei jedem Abruf neu erzeugt.
* Lease-Dauer 0 fällt in dieselbe Kategorie.
*/
$adressfelder = array('server', 'dhcpServer', 'gateway', 'dns', 'ipv4', 'ip');
if (in_array($key, $adressfelder, true) && ($val === 0 || $val === '0' || $val === '0.0.0.0')) {
return $label.': nicht ermittelbar';
}
if (in_array($key, array('lease', 'leaseSec'), true) && ($val === 0 || $val === '0')) {
return $label.': nicht ermittelbar';
}
// Sekundenwerte lesbar machen statt sechsstellige Zahlen zu drucken
if ($einheit === 's' && is_numeric($val) && $val >= 3600) {
return $label.': '.netdiagDauerLesbar($val);
}
$text = netdiagKundentext((string) $val);
/*
* Einheit nicht doppelt anhängen. Ältere App-Fassungen haben sie in den
* Wert geschrieben („864000 s"); im Kundendokument stand dadurch
* „Lease-Dauer: 864000 s s". Nachgewiesen an der Prod-Messung #42.
*/
if ($einheit !== '' && preg_match('/\s'.preg_quote($einheit, '/').'$/u', $text)) {
return $label.': '.$text;
}
return $label.': '.$text.($einheit !== '' ? ' '.$einheit : '');
}
/**
* Interne Formulierungen aus Alt-Datensätzen für das Kundendokument
* entschärfen.
*
* Beispiel aus einem echten Protokoll: „ARP-Tabelle nicht lesbar
* (/proc/net/arp) — braucht Root". Das ist eine Aussage über das Messgerät,
* nicht über das Kundennetz, und gehört nicht ins Abnahmedokument. Neue
* Messungen liefern gleich einen Kundentext; für die bereits gespeicherten
* Ergebnisse hilft nur ein Ersetzen beim Drucken.
*
* @param string $text Rohtext aus dem Ergebnis
* @return string kundentauglicher Text
*/
function netdiagKundentext($text)
{
if (stripos($text, '/proc/net/arp') !== false || stripos($text, 'braucht root') !== false) {
return 'Mit dem eingesetzten Messgerät nicht prüfbar — die Aussage bleibt offen.';
}
// Anschlussart: Android liefert die Rohwerte englisch. Im Abnahmeprotokoll
// eines deutschen Handwerksbetriebs hat „Anschlussart: ethernet" nichts zu
// suchen. Exakter Vergleich statt stripos, damit nicht versehentlich ein
// Fließtext getroffen wird, in dem das Wort vorkommt.
$anschluss = array(
'wifi' => 'WLAN',
'ethernet' => 'LAN (Kabel)',
'cellular' => 'Mobilfunk',
'vpn' => 'VPN',
'other' => 'sonstige',
);
$k = strtolower(trim($text));
if (isset($anschluss[$k])) {
return $anschluss[$k];
}
return $text;
}
/**
* Klarname eines Werkzeugs für die Anzeige.
*
* Die kanonischen Namen stehen eigentlich in der App (`Tool.name`), das
* Dolibarr-Modul kann darauf aber nicht zugreifen. Deshalb hier eine bewusst
* kurze Zuordnung statt eines zweiten Satzes Sprachschlüssel — mit Rückfall
* auf die ID, damit ein neues Werkzeug nie einen leeren Titel bekommt.
*
* Enthält auch Werkzeuge, die es in der App nicht mehr gibt („dhcpcheck",
* „wifiscan"): in der Produktionsdatenbank stehen dazu noch Messungen aus
* älteren Protokollen, und die sollen weiterhin lesbar sein.
*
* @param string $tool Werkzeug-ID aus der Messung
* @return string Klarname, oder die ID selbst wenn unbekannt
*/
function netdiagToolName($tool)
{
$map = array(
'ipscan' => 'IP-Scanner',
'portscan' => 'Port-Scan',
'ping' => 'Ping / Qualität',
'ipconflict' => 'IP-Konflikt',
'snmp' => 'SNMP-Switch',
'traceroute' => 'Traceroute',
'stresstest' => 'Dauer-/Stresstest',
'iperf' => 'Durchsatz-Test',
'internetkette' => 'Internet-Kette',
'wifikanal' => 'WLAN-Kanalanalyse',
'monitor' => 'Geräte-Monitor',
'iptest' => 'IP-Test (Dose)',
'wifitrack' => 'WLAN-Empfang',
// nicht mehr vorhandene Werkzeuge, aber in Altprotokollen enthalten
'dhcpcheck' => 'DHCP-Prüfung',
'wifiscan' => 'WLAN-Scan',
);
return $map[$tool] ?? $tool;
}
/**
* Messparameter für die Anzeige aufbereiten („Ziel: 192.168.1.1").
*
* Ohne sie steht im Protokoll zwar das Ergebnis, aber nicht, worauf es sich
* bezieht — bei Prod-Messung #126 war deshalb nicht erkennbar, gegen welches
* Ziel der Dauertest lief. Nur ausgefüllte Parameter erscheinen.
*
* @param string $json JSON-String der Parameter
* @return string lesbarer Text, leer wenn nichts anzuzeigen ist
*/
function netdiagFormatParams($json)
{
if (empty($json)) {
return '';
}
$data = json_decode($json, true);
if (!is_array($data) || empty($data)) {
return '';
}
$labels = array(
'host' => 'Ziel',
'subnet' => 'Netzbereich',
'ip' => 'Gerät',
'ports' => 'Ports',
'port' => 'Port',
'count' => 'Anzahl Proben',
'durationSec' => 'Dauer (s)',
'intervalSec' => 'Messabstand (s)',
'community' => 'SNMP-Community',
'rounds' => 'Runden',
'timeoutMs' => 'Zeitgrenze (ms)',
);
$teile = array();
foreach ($data as $k => $v) {
if ($v === null || $v === '' || is_array($v)) {
continue;
}
$teile[] = ($labels[$k] ?? ucfirst((string) $k)).': '.$v;
}
return implode(' · ', $teile);
}