[ "titel" => "SolarManager", "datei" => "solarOutput.log", "symbol" => "bi-sun", "hinweis" => "Messwerte einsammeln, Wallbox und Heizung regeln.", ], "runner" => [ "titel" => "AutoAction-Runner", "datei" => "autoActions.log", "symbol" => "bi-diagram-3", "hinweis" => "Wertet die Automatiken aus und schickt die Kommandos.", ], "regen" => [ "titel" => "Regen", "datei" => "rainOutput.log", "symbol" => "bi-cloud-rain", "hinweis" => "Holt die Niederschlagsdaten von Open-Meteo und legt " . "sie nach Wetter/Regen, damit die Bewässerungs-" . "Automatiken sie als Messwert bekommen.", ], "wattpilot" => [ "titel" => "Wattpilot", "datei" => "wattpilotshell.log", "symbol" => "bi-ev-station", "hinweis" => "Bruecke zur Wallbox.", ], "mqtt" => [ "titel" => "MQTT-Brücke", "datei" => "wsMQTTbridge.log", "symbol" => "bi-broadcast", "hinweis" => "Hört den Websocket der Wetterstation ab und reicht " . "die Messwerte an den MQTT-Broker weiter " . "(Topic weatherStation).", ], "wecker" => [ "titel" => "Wecker", "datei" => "wecker.log", "symbol" => "bi-alarm", "hinweis" => "Stellt morgens die Rollläden und das Licht.", ], ]; } /** * Beschreibung einer Datei samt Zustand: Groesse, Alter, ob lesbar. * * Die Seite zeigt das an, damit man eine stehengebliebene Datei erkennt, * ohne sie erst zu oeffnen - ein Prozess, der seit Tagen nichts geschrieben * hat, ist ein Befund fuer sich. */ function logZustand($key) { $dateien = logDateien(); if (!isset($dateien[$key])) { throw new InvalidArgumentException("Unbekannte Logdatei: " . $key); } $eintrag = $dateien[$key]; $pfad = LOG_BASIS . "/" . $eintrag["datei"]; $zustand = [ "key" => $key, "titel" => $eintrag["titel"], "datei" => $eintrag["datei"], "symbol" => $eintrag["symbol"], "hinweis" => $eintrag["hinweis"], "da" => false, "lesbar" => false, "groesse" => 0, "stand" => null, ]; if (!is_file($pfad)) { return $zustand; } $zustand["da"] = true; $zustand["lesbar"] = is_readable($pfad); $zustand["groesse"] = (int) filesize($pfad); $zustand["stand"] = date("Y-m-d H:i:s", filemtime($pfad)); return $zustand; } /** * Level einer Zeile, oder "" wenn keines drinsteht. * * Die beiden Prozesse formatieren unterschiedlich - der Runner schreibt * "2026-09-03 18:34:25,108 - INFO - Text", der SolarManager stellt das Level * nach vorn. Deshalb wird es gesucht und nicht an einer festen Stelle * erwartet. Zeilen ohne Level sind meist Fortsetzungen: die zweite bis * letzte Zeile eines Tracebacks. */ function logLevel($zeile) { if (preg_match('/\b(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL|FATAL)\b/', $zeile, $t)) { return $t[1] === "WARN" ? "WARNING" : ($t[1] === "FATAL" ? "CRITICAL" : $t[1]); } return ""; } /** * Eine Rohzeile in Zeitstempel, Level und Text zerlegen. * * Was nicht erkannt wird, bleibt im Text stehen - lieber eine Zeile, die * roh aussieht, als eine, von der die Haelfte fehlt, weil ein Muster nicht * gepasst hat. */ function logZerlegen($zeile) { $zeit = null; $text = $zeile; // Der SolarManager schreibt "INFO 2026-09-03 18:29:01,123 /langer/pfad/x.py x.py - Text". // Der Pfad davor steht zweimal da und ist im Browser nur Ballast; der // blosse Dateiname sagt dasselbe. $text = preg_replace('#(\s)/\S+/(\S+\.py)(\s+\2\s+-\s)#', '$1$2 - ', $text, 1); if (preg_match('/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?)/', $text, $t)) { $zeit = str_replace(",", ".", $t[1]); // Nur den Zeitstempel entfernen, nicht alles davor: beim // SolarManager steht links davon das Level. $text = trim(str_replace($t[1], "", $text)); } $level = logLevel($text); if ($level !== "") { // Das Level und die Trennzeichen drumherum wegnehmen, aber nur am // Anfang - ein "ERROR" mitten im Satz bleibt stehen. $text = preg_replace('/^[\s\-:]*' . preg_quote($level, '/') . '[\s\-:]*/', '', $text, 1); } return ["t" => $zeit, "l" => $level, "m" => $text]; } /** * Text so zurechtruecken, dass json_encode ihn nicht verweigert. * * In einem Log steht irgendwann eine Zeile, die kein gueltiges UTF-8 ist - * ein abgeschnittener Umlaut, eine Fremdbibliothek in Latin-1. json_encode * gibt dann fuer die ganze Antwort false zurueck, und die Seite bliebe leer. */ function logText($s) { return mb_check_encoding($s, "UTF-8") ? $s : mb_convert_encoding($s, "UTF-8", "ISO-8859-1"); } /** * Die letzten Zeilen einer Logdatei, optional gefiltert. * * Gelesen wird rueckwaerts in Bloecken: erst der letzte, dann bei Bedarf der * davor. So kostet ein Blick in eine 300-MB-Datei so viel wie einer in eine * kleine, solange genug Treffer im letzten Stueck stehen. * * Innerhalb eines Blocks wird vorwaerts gearbeitet, damit Zeilen ohne * eigenes Level - Tracebacks - das Level der Zeile darueber erben. Sonst * verschwaende der Filter "nur Fehler" genau die Zeilen, die den Fehler * erklaeren. * * @param string $key Schluessel aus logDateien() * @param int $anzahl gewuenschte Zeilen * @param string $suche Text, der vorkommen muss (leer = alles) * @param string $level Mindestlevel: "", "INFO", "WARNING", "ERROR" */ function logLesen($key, $anzahl = 300, $suche = "", $level = "") { $zustand = logZustand($key); $pfad = LOG_BASIS . "/" . $zustand["datei"]; if (!$zustand["da"]) { throw new RuntimeException("Die Datei " . $zustand["datei"] . " gibt es (noch) nicht."); } if (!$zustand["lesbar"]) { throw new RuntimeException("Die Datei " . $zustand["datei"] . " ist für den Webserver nicht lesbar."); } $anzahl = max(1, min(5000, (int) $anzahl)); $rang = ["DEBUG" => 0, "" => 0, "INFO" => 1, "WARNING" => 2, "ERROR" => 3, "CRITICAL" => 4]; $mindest = $rang[$level] ?? 0; $fh = fopen($pfad, "rb"); if (!$fh) { throw new RuntimeException("Die Datei " . $zustand["datei"] . " ließ sich nicht öffnen."); } $groesse = $zustand["groesse"]; $block = 128 * 1024; $gelesen = 0; $rest = ""; // angefangene Zeile am Blockanfang $treffer = []; // gesammelt, neueste zuerst $abgeschnitten = false; while ($gelesen < $groesse && count($treffer) < $anzahl) { $laenge = (int) min($block, $groesse - $gelesen); fseek($fh, $groesse - $gelesen - $laenge, SEEK_SET); $roh = fread($fh, $laenge) . $rest; $gelesen += $laenge; $zeilen = explode("\n", $roh); // Die erste Zeile des Blocks ist angeschnitten, solange noch etwas // davor liegt - sie wandert in den naechsten Durchgang. $rest = ($gelesen < $groesse) ? array_shift($zeilen) : ""; // Vorwaerts: Level fortschreiben, damit Fortsetzungszeilen eins erben. $vorbereitet = []; $letztesLevel = ""; foreach ($zeilen as $zeile) { $zeile = rtrim($zeile, "\r"); if ($zeile === "") { continue; } $teil = logZerlegen(logText($zeile)); if ($teil["l"] === "") { $teil["l"] = $letztesLevel; $teil["fortsetzung"] = true; } else { $letztesLevel = $teil["l"]; $teil["fortsetzung"] = false; } $vorbereitet[] = $teil; } // Rueckwaerts einsammeln: die juengsten Zeilen zuerst. for ($i = count($vorbereitet) - 1; $i >= 0 && count($treffer) < $anzahl; $i--) { $teil = $vorbereitet[$i]; if (($rang[$teil["l"]] ?? 0) < $mindest) { continue; } if ($suche !== "" && mb_stripos($teil["m"], $suche) === false && mb_stripos((string) $teil["t"], $suche) === false) { continue; } $treffer[] = $teil; } if ($gelesen >= LOG_MAX_BYTES && count($treffer) < $anzahl && $gelesen < $groesse) { $abgeschnitten = true; break; } } fclose($fh); return [ "zustand" => $zustand, "zeilen" => array_reverse($treffer), "abgeschnitten" => $abgeschnitten, "durchsucht" => $gelesen, ]; }