Compare commits

..

3 commits

Author SHA1 Message Date
5bb5756604 Trigger: Modul-Deploy [deploy]
All checks were successful
Deploy netdiag / deploy (push) Successful in 14s
2026-08-16 11:22:18 +02:00
7186be64da Kundenfeld-Whitelist trifft jetzt die real gelieferten Schlüssel
Regression aus dem vorherigen Commit, gefunden bei der Bestandsprüfung von
Phase 6: netdiagKundenfelder() war gegen selbst erzeugte Testdaten gebaut,
nicht gegen die Namen, welche die App-Werkzeuge tatsächlich liefern.

Konkret liefert die App "ziel"/"zielErreicht" (Traceroute), "gegenstelle"/
"downloadMbps"/"uploadMbps" (Durchsatz), "scanned"/"open" (Portscan),
"geprueft"/"runden"/"konflikte" (IP-Konflikt), "port"/"linkSpeed"/
"eingangsFehler"/"ausgangsFehler" (SNMP) sowie das gespreadete LinkInfo beim
IP-Test. Keiner dieser Schlüssel stand in der Tabelle — und weil
netdiagFeldFuerKunde() unbekannte Schlüssel verwirft, wäre im Kundendokument
von diesen fünf Werkzeugen nur noch die Ampel übrig geblieben, ohne einen
einzigen Messwert. Das ist schlechter als der Zustand davor (rohe Schlüssel).

Alle real emittierten Schlüssel ergänzt und gegen die echten Ergebnis-
Strukturen geprüft, Beispiel Portscan:
  "Gerät: 192.168.1.50 | Geprüfte Ports: 10 | Offene Ports: 80/http, 443/https"

Lehre für künftige Werkzeuge: die Tabelle ist eine Whitelist, ein neues
Werkzeug braucht dort einen Eintrag — sonst verschwinden seine Messwerte
stillschweigend aus dem Kundendokument.
2026-08-16 11:22:18 +02:00
d079431cdf Phase 5 abgeschlossen: Klarnamen, Parameter, Listen-Ampel, N+1, Standort
- Werkzeug-Klarnamen statt interner IDs: "IP-Scanner — 46 Geräte im Netz …"
  statt "[netzwerk] ipscan — …". Bewusst eine kurze Zuordnung in
  netdiagToolName() statt eines zweiten Satzes Sprachschlüssel — die
  kanonischen Namen stehen in der App, doppelte Pflege wäre eine Fehlerquelle.
  Enthält auch dhcpcheck/wifiscan: in der App gibt es sie nicht mehr, in der
  PRODUKTIONSDATENBANK stehen dazu aber noch Messungen (4 bzw. 2). Die
  Gegenprüfung hielt den Punkt für gegenstandslos, hatte dabei aber nur die
  Testdatenbank angesehen.

- Messparameter anzeigen (Prod-Messung #126): unter jeder Messung steht jetzt
  "Ziel: 192.168.1.1 · Dauer (s): 300" in Karte und PDF. Vorher stand das
  Ergebnis ohne Bezugspunkt da — man sah nicht, wogegen gemessen wurde.

- Listenseite: Ampel je Protokoll (schlechteste Einzelmessung) plus Anzahl,
  dazu ein Filter "nur mit Befund". Status 3 "nicht messbar" geht bewusst
  NICHT ins Maximum ein — er ist keine Aussage über das Kundennetz — sondern
  wird separat als "n.m." ausgewiesen. Im Browser geprüft: der Filter liefert
  ausschließlich Protokolle mit Warnung oder Fehler.

- N+1-Queries behoben: fetchAllByProtocol() las nur die rowids und setzte je
  Zeile ein eigenes fetch() ab. Jetzt eine Abfrage mit setVarsFromFetchObj(),
  zusätzlich mit Entity-Filter (fehlte bisher ganz). Nachgemessen über
  SHOW SESSION STATUS: 46 Geräte + 7 Messungen brauchen statt 55 Abfragen
  noch eine.

- Standort aus der Kundenadresse vorbelegen, wenn der Techniker nichts
  eingetragen hat; eine vorhandene Angabe wird nie überschrieben. Über die
  echte API geprüft.

Offen bleibt aus Phase 5 nur der Vergleich zweier Protokolle (Geräte-Diff
nach MAC) — eigenes Feature mit eigener Ansicht.
2026-08-16 11:09:28 +02:00
9 changed files with 210 additions and 14 deletions

View file

@ -144,6 +144,18 @@ $protocol->fk_commande = !empty($p['orderId']) ? (int) $p['orderId'] : nul
$protocol->date_diag = netdiag_api_timestamp($p['dateDiag'] ?? 0);
$protocol->fk_user_techniker = (int) $user->id;
$protocol->standort = isset($p['location']) ? (string) $p['location'] : '';
// Standort leer? Dann aus der Kundenadresse vorbelegen. Der Techniker tippt
// vor Ort selten etwas ein, im Protokoll stand der Ort deshalb fast immer
// leer — obwohl er in Dolibarr am Kunden hinterlegt ist. Eine vom Techniker
// eingetragene Angabe wird NIE überschrieben.
if (trim($protocol->standort) === '' && !empty($protocol->fk_soc)) {
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
$socTmp = new Societe($db);
if ($socTmp->fetch((int) $protocol->fk_soc) > 0) {
$teile = array_filter(array(trim($socTmp->address), trim($socTmp->zip.' '.$socTmp->town)));
$protocol->standort = substr(implode(', ', $teile), 0, 255);
}
}
$protocol->subnet = isset($p['subnet']) ? (string) $p['subnet'] : '';
$protocol->status = isset($p['status']) ? (int) $p['status'] : NetDiagProtocol::STATUS_DRAFT;
$protocol->note = isset($p['note']) ? (string) $p['note'] : '';

View file

@ -135,18 +135,24 @@ class NetDiagDevice extends CommonObject
*/
public function fetchAllByProtocol($fk_protocol)
{
// Alle Spalten in EINER Abfrage holen. Vorher wurden nur die rowids
// gelesen und je Zeile ein eigenes fetch() abgesetzt — bei 46 Geräten
// also 47 Abfragen statt einer. Die Kartenansicht und der
// PDF-Generator laden diese Liste jeweils komplett.
$result = array();
$sql = "SELECT rowid FROM ".$this->db->prefix().$this->table_element;
$sql = "SELECT * FROM ".$this->db->prefix().$this->table_element;
$sql .= " WHERE fk_protocol = ".((int) $fk_protocol);
$sql .= " AND entity IN (".getEntity($this->element).")";
$sql .= " ORDER BY INET_ATON(ip), rowid";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$dev = new self($this->db);
if ($dev->fetch((int) $obj->rowid) > 0) {
$result[] = $dev;
}
$dev->setVarsFromFetchObj($obj);
$dev->id = (int) $obj->rowid;
$result[] = $dev;
}
$this->db->free($resql);
}
return $result;
}

View file

@ -128,18 +128,21 @@ class NetDiagMeasurement extends CommonObject
*/
public function fetchAllByProtocol($fk_protocol)
{
// Eine Abfrage statt einer je Messung (siehe NetDiagDevice::fetchAllByProtocol)
$result = array();
$sql = "SELECT rowid FROM ".$this->db->prefix().$this->table_element;
$sql = "SELECT * FROM ".$this->db->prefix().$this->table_element;
$sql .= " WHERE fk_protocol = ".((int) $fk_protocol);
$sql .= " AND entity IN (".getEntity($this->element).")";
$sql .= " ORDER BY date_measure, rowid";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$m = new self($this->db);
if ($m->fetch((int) $obj->rowid) > 0) {
$result[] = $m;
}
$m->setVarsFromFetchObj($obj);
$m->id = (int) $obj->rowid;
$result[] = $m;
}
$this->db->free($resql);
}
return $result;
}

View file

@ -74,3 +74,4 @@ NetDiagFoundViaPort=Port
NetDiagFoundViaArp=ARP
NetDiagFoundViaMdns=mDNS
NetDiagLastSeen=Zuletzt gesehen
NetDiagOnlyWithFindings=nur mit Befund

View file

@ -74,3 +74,4 @@ NetDiagFoundViaPort=Port
NetDiagFoundViaArp=ARP
NetDiagFoundViaMdns=mDNS
NetDiagLastSeen=Last seen
NetDiagOnlyWithFindings=only with findings

View file

@ -344,6 +344,44 @@ function netdiagKundenfelder()
'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', ''),
'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', ''),
'typ' => array('Anschlussart', ''),
'neu' => array('Neu hinzugekommen', ''),
'nichtMehrErreichbar' => array('Nicht mehr erreichbar', ''),
'demodaten' => array('DEMODATEN — keine echte Messung', ''),
// 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.
@ -430,3 +468,82 @@ function netdiagKundentext($text)
}
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',
'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);
}

View file

@ -181,10 +181,19 @@ function netdiagGeneratePdf($db, $protocol, $outputlangs)
// wie ein abgebrochenes Dokument.
netdiagPdfEnsureSpace($pdf, 11);
$pdf->SetFont('', 'B', 9);
$title = ($m->category ? '['.$m->category.'] ' : '').$m->tool.($m->label ? ' — '.$m->label : '');
// Klarname statt interner ID — „ipscan" sagt dem Kunden nichts
$title = netdiagToolName($m->tool).($m->label ? ' — '.$m->label : '');
$pdf->Cell(150, 6, dol_trunc($title, 80), 1, 0, 'L');
$pdf->Cell(30, 6, $statusmarks[$st].$statuslabels[$st], 1, 1, 'C', true);
$pdf->SetFont('', '', 8);
// Messparameter: ohne sie steht das Ergebnis ohne Bezug da — man sieht
// nicht, gegen welches Ziel gemessen wurde (Prod-Messung #126).
$params = netdiagFormatParams($m->params);
if ($params !== '') {
$pdf->SetFont('', 'I', 7);
$pdf->MultiCell(180, 4, $params, 1, 'L');
$pdf->SetFont('', '', 8);
}
if ($m->tool === 'stresstest') {
netdiagPdfStressTest($pdf, $m->result);
} elseif ($m->tool === 'wifikanal') {

View file

@ -67,6 +67,10 @@ if (!$user->hasRight('netdiag', 'protocol', 'read')) {
$search_ref = GETPOST('search_ref', 'alpha');
$search_soc = GETPOST('search_soc', 'alpha');
// „nur mit Befund": zeigt Protokolle, in denen mindestens eine Messung
// Warnung oder Fehler ist — beim Durchsehen vieler Protokolle die eigentliche
// Frage („wo muss ich nochmal ran?")
$search_problem = GETPOST('search_problem', 'int');
$limit = GETPOSTINT('limit') ? GETPOSTINT('limit') : $conf->liste_limit;
$page = GETPOSTINT('page');
if ($page < 0) {
@ -85,7 +89,16 @@ llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-netdiag page-list');
// Datensätze laden
$sql = "SELECT p.rowid, p.ref, p.label, p.date_diag, p.standort, p.subnet, p.status,";
$sql .= " s.rowid as socid, s.nom as socname,";
$sql .= " (SELECT COUNT(*) FROM ".$db->prefix()."netdiag_device d WHERE d.fk_protocol = p.rowid) as devcount";
$sql .= " (SELECT COUNT(*) FROM ".$db->prefix()."netdiag_device d WHERE d.fk_protocol = p.rowid) as devcount,";
// Schlechteste Einzelmessung als Protokoll-Ampel. Status 3 („nicht messbar")
// darf dabei NICHT als schlechtester Wert gelten — er ist keine Aussage über
// das Kundennetz; deshalb wird er separat gezählt statt in MAX() einzugehen.
$sql .= " (SELECT MAX(m.measure_status) FROM ".$db->prefix()."netdiag_measurement m
WHERE m.fk_protocol = p.rowid AND m.measure_status IN (0,1,2)) as worststatus,";
$sql .= " (SELECT COUNT(*) FROM ".$db->prefix()."netdiag_measurement m
WHERE m.fk_protocol = p.rowid) as meascount,";
$sql .= " (SELECT COUNT(*) FROM ".$db->prefix()."netdiag_measurement m
WHERE m.fk_protocol = p.rowid AND m.measure_status = 3) as unmesscount";
$sql .= " FROM ".$db->prefix()."netdiag_protocol as p";
$sql .= " LEFT JOIN ".$db->prefix()."societe as s ON s.rowid = p.fk_soc";
$sql .= " WHERE p.entity IN (".getEntity('netdiagprotocol').")";
@ -95,6 +108,10 @@ if ($search_ref) {
if ($search_soc) {
$sql .= natural_search('s.nom', $search_soc);
}
if ($search_problem) {
$sql .= " AND EXISTS (SELECT 1 FROM ".$db->prefix()."netdiag_measurement m2
WHERE m2.fk_protocol = p.rowid AND m2.measure_status IN (1,2))";
}
$sql .= " ORDER BY p.date_diag DESC, p.rowid DESC";
$sql .= $db->plimit($limit, $offset);
@ -116,7 +133,8 @@ print '<table class="tagtable liste">';
print '<tr class="liste_titre">';
print '<td><input type="text" name="search_ref" class="maxwidth100" value="'.dol_escape_htmltag($search_ref).'" placeholder="'.$langs->trans("Ref").'"></td>';
print '<td><input type="text" name="search_soc" class="maxwidth150" value="'.dol_escape_htmltag($search_soc).'" placeholder="'.$langs->trans("ThirdParty").'"></td>';
print '<td colspan="4" class="right"><input type="submit" class="button small" value="'.$langs->trans("Search").'"></td>';
print '<td colspan="3" class="right"><label class="opacitymedium small"><input type="checkbox" name="search_problem" value="1"'.($search_problem ? ' checked' : '').'> '.$langs->trans("NetDiagOnlyWithFindings").'</label></td>';
print '<td class="right"><input type="submit" class="button small" value="'.$langs->trans("Search").'"></td>';
print '</tr>';
print '<tr class="liste_titre">';
@ -125,6 +143,7 @@ print '<th>'.$langs->trans("ThirdParty").'</th>';
print '<th>'.$langs->trans("DateDiag").'</th>';
print '<th>'.$langs->trans("Location").'</th>';
print '<th class="right">'.$langs->trans("NetDiagDevices").'</th>';
print '<th class="center">'.$langs->trans("NetDiagMeasurements").'</th>';
print '<th class="center">'.$langs->trans("Status").'</th>';
print '</tr>';
@ -143,11 +162,33 @@ foreach ($rows as $obj) {
print '<td>'.dol_print_date($db->jdate($obj->date_diag), 'dayhour').'</td>';
print '<td>'.dol_escape_htmltag($obj->standort).'</td>';
print '<td class="right">'.((int) $obj->devcount).'</td>';
// Messungs-Ampel: schlechteste Einzelmessung, plus Hinweis auf nicht
// messbare Punkte. So sieht man in der Liste, wo noch etwas zu tun ist,
// ohne jedes Protokoll einzeln zu öffnen.
print '<td class="center">';
$meascount = (int) $obj->meascount;
if ($meascount === 0) {
print '<span class="opacitymedium">-</span>';
} else {
$worst = ($obj->worststatus === null) ? 3 : (int) $obj->worststatus;
if ($worst < 0 || $worst > 3) {
$worst = 1;
}
$ampellabels = array(0 => 'NetDiagMeasureOk', 1 => 'NetDiagMeasureWarn', 2 => 'NetDiagMeasureFail', 3 => 'NetDiagMeasureUnmeasurable');
$ampelcss = array(0 => 'badge-status4', 1 => 'badge-status1', 2 => 'badge-status8', 3 => 'badge-status0');
print '<span class="badge '.$ampelcss[$worst].'">'.$langs->trans($ampellabels[$worst]).'</span>';
print ' <span class="opacitymedium small">'.$meascount;
if ((int) $obj->unmesscount > 0) {
print ', '.((int) $obj->unmesscount).' n.m.';
}
print '</span>';
}
print '</td>';
print '<td class="center">'.$proto->getLibStatut(3).'</td>';
print '</tr>';
}
if (empty($rows)) {
print '<tr><td colspan="6" class="opacitymedium center">'.$langs->trans("NoRecordFound").'</td></tr>';
print '<tr><td colspan="7" class="opacitymedium center">'.$langs->trans("NoRecordFound").'</td></tr>';
}
print '</table>';

View file

@ -260,8 +260,14 @@ foreach ($measurements as $m) {
print '<tr class="oddeven">';
print '<td class="nowraponall">'.dol_print_date($m->date_measure, 'dayhour').'</td>';
print '<td>'.dol_escape_htmltag($m->category).'</td>';
print '<td>'.dol_escape_htmltag($m->tool).'</td>';
print '<td>'.dol_escape_htmltag($m->label).'</td>';
print '<td>'.dol_escape_htmltag(netdiagToolName($m->tool)).'</td>';
print '<td>'.dol_escape_htmltag($m->label);
// Messparameter darunter: sonst ist nicht erkennbar, wogegen gemessen wurde
$params = netdiagFormatParams($m->params);
if ($params !== '') {
print '<br><span class="opacitymedium small">'.dol_escape_htmltag($params).'</span>';
}
print '</td>';
print '<td>'.netdiagFormatResult($m->result, $m->tool).'</td>';
print '<td class="center"><span class="badge '.$statuscss[$st].'">'.$langs->trans($statuslabels[$st]).'</span></td>';
print '</tr>';