Bisher kam man an die Ausgabe von solarManager.py, dem Runner und den uebrigen Dauerprozessen nur per SSH heran. Die neue Seite unter "Protokolle" zeigt sie: links die fuenf Dateien mit Groesse und Alter - eine, die seit Tagen nicht gewachsen ist, faellt so auf, ohne dass man sie oeffnet -, rechts die Zeilen mit Volltextsuche, Level-Filter und optionalem Nachladen. Nicht in die Datenbank geschrieben, obwohl das naheliegt: ein Log muss genau dann noch funktionieren, wenn die Datenbank es nicht tut. Ein Handler, der nach MySQL schreibt, verschluckt ausgerechnet die Meldung "Datenbank nicht erreichbar" - also die, wegen der man nachsieht. Was strukturiert ausgewertet werden soll, steht ohnehin in automation_log. Gelesen wird rueckwaerts in Bloecken: 5000 Zeilen aus der 329 MB grossen Datei der Wallbox kosten 46 ms, weil nur das Ende angefasst wird. Sucht man ins Leere, bricht der Server nach 4 MB ab und sagt es auch. Die beiden Prozesse formatieren unterschiedlich - der eine stellt das Level voran, der andere den Zeitstempel -, deshalb wird beides gesucht statt an fester Stelle erwartet. Zeilen ohne eigenes Level erben das der Zeile darueber, sonst filterte "nur Fehler" die Tracebacks weg, die den Fehler erklaeren. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
272 lines
9.6 KiB
PHP
272 lines
9.6 KiB
PHP
<?php
|
|
/**
|
|
* Welche Logdateien es gibt und wie man hineinschaut.
|
|
*
|
|
* Die Hintergrundprozesse liegen seit dem Umzug im SolarManager-Verzeichnis
|
|
* und schreiben ihre Ausgabe jeweils in eine eigene Datei. Diese Seite liest
|
|
* sie nur - geschrieben wird hier nichts.
|
|
*
|
|
* Warum Dateien und nicht die Datenbank: ein Log muss genau dann noch
|
|
* funktionieren, wenn die Datenbank es nicht tut. Ein Handler, der seine
|
|
* Zeilen nach MySQL schreibt, verschluckt ausgerechnet die Meldung
|
|
* "Datenbank nicht erreichbar" - also die, wegen der man nachsieht. Was
|
|
* strukturiert ausgewertet werden soll, steht ohnehin schon in einer Tabelle:
|
|
* automation_log fuer die Automatiken.
|
|
*
|
|
* Gelesen wird immer vom Dateiende her, blockweise. wattpilotshell.log ist
|
|
* dreistellig megabytegross; ein file() darauf wuerde den PHP-Prozess
|
|
* umbringen.
|
|
*/
|
|
|
|
// Wo die Hintergrundprozesse liegen. Steht hier als Konstante und nicht in
|
|
// einer Konfigurationsdatei, weil es genau einen solchen Rechner gibt - und
|
|
// weil ein falscher Pfad hier sofort auffaellt statt still ins Leere zu
|
|
// laufen.
|
|
const LOG_BASIS = "/volume1/homes/wagner/SolarManager";
|
|
|
|
// Wie weit hoechstens zurueckgelesen wird, wenn gefiltert ist. Ohne Grenze
|
|
// wuerde die Suche nach einem Wort, das nirgends vorkommt, die komplette
|
|
// Datei durch den Speicher schieben.
|
|
const LOG_MAX_BYTES = 4 * 1024 * 1024;
|
|
|
|
/**
|
|
* Die bekannten Logdateien. Der Schluessel steht in der URL - der Pfad kommt
|
|
* ausschliesslich von hier und nie aus der Anfrage.
|
|
*/
|
|
function logDateien()
|
|
{
|
|
return [
|
|
"solar" => [
|
|
"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.",
|
|
],
|
|
"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" => "Reicht die Messwerte an den Browser weiter.",
|
|
],
|
|
"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,
|
|
];
|
|
}
|