Files
Smart-Dashboard/restricted/logdateien.php
T
adminandClaude Opus 5 cbc9353f0a Protokollseite: die Logs der Hintergrundprozesse im Dashboard
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>
2026-09-03 19:26:56 +02:00

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,
];
}