diff --git a/ajax/logs.php b/ajax/logs.php new file mode 100644 index 0000000..b712523 --- /dev/null +++ b/ajax/logs.php @@ -0,0 +1,59 @@ + $liste]); + + case "read": + jsonAntwort(logLesen( + $_GET["datei"] ?? "", + $_GET["anzahl"] ?? 300, + trim((string) ($_GET["suche"] ?? "")), + strtoupper(trim((string) ($_GET["level"] ?? ""))) + )); + + default: + jsonAntwort(["error" => "Unbekannte Aktion."], 400); + } +} catch (InvalidArgumentException $e) { + jsonAntwort(["error" => $e->getMessage()], 400); +} catch (Throwable $e) { + jsonAntwort(["error" => $e->getMessage()], 500); +} diff --git a/css/solar.css b/css/solar.css index 20851b9..5be521c 100644 --- a/css/solar.css +++ b/css/solar.css @@ -906,3 +906,64 @@ input[type='range']::-webkit-slider-runnable-track { } .skoda-alter { font-size: .85rem; } + +/* === Protokollseite ====================================================== + Ein Log liest sich nur in einer Schrift mit fester Breite: die Zeitstempel + stehen dann untereinander, und das Auge findet die Stelle, an der etwas + anders wird. Die Zeilen umbrechen statt seitlich zu scrollen - ein + Traceback ist lang, und waagerecht zu blaettern verliert man sofort. */ +.log-ausgabe { + max-height: calc(100vh - 22rem); + min-height: 20rem; + overflow-y: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: .78rem; + line-height: 1.45; + background: var(--bs-body-bg, #1b1e21); + border: 1px solid var(--bs-border-color, #2c3034); + border-radius: .375rem; +} + +.log-ausgabe .log-zeile { + display: flex; + gap: .6rem; + padding: .1rem .6rem; + border-left: 3px solid transparent; + white-space: pre-wrap; + word-break: break-word; +} +.log-ausgabe .log-zeile:hover { background: rgba(255, 255, 255, .04); } + +.log-ausgabe .log-zeit { flex: 0 0 5rem; color: var(--bs-secondary-color, #8b9198); } +.log-ausgabe .log-level { flex: 0 0 4.5rem; font-weight: 600; } +.log-ausgabe .log-text { flex: 1 1 auto; } + +/* Fortsetzungszeilen - die zweite bis letzte Zeile eines Tracebacks - tragen + kein eigenes Level. Sie bleiben in der Farbe ihrer Kopfzeile, ruecken aber + ein: so sieht man auf einen Blick, was zusammengehoert. */ +.log-ausgabe .log-fortsetzung .log-text { padding-left: 1.2rem; opacity: .85; } + +.log-ausgabe .log-debug { color: var(--bs-secondary-color, #8b9198); } +.log-ausgabe .log-info .log-level { color: #6ea8dc; } +.log-ausgabe .log-warning { border-left-color: #d9a441; background: rgba(217, 164, 65, .07); } +.log-ausgabe .log-warning .log-level { color: #d9a441; } +.log-ausgabe .log-error { border-left-color: #e06060; background: rgba(224, 96, 96, .09); } +.log-ausgabe .log-error .log-level { color: #f08a8a; } + +/* Tagestrenner: das Datum steht einmal da, nicht in jeder Zeile. */ +.log-ausgabe .log-tag { + position: sticky; + top: 0; + padding: .15rem .6rem; + font-size: .72rem; + letter-spacing: .04em; + color: var(--bs-secondary-color, #8b9198); + background: var(--bs-tertiary-bg, #23272b); + border-top: 1px solid var(--bs-border-color, #2c3034); + border-bottom: 1px solid var(--bs-border-color, #2c3034); +} + +/* Die Dateiliste links: der aktive Eintrag wird von AdminLTE eingefaerbt, + die Zustandszeile darunter soll trotzdem lesbar bleiben. */ +.log-liste .log-wahl.active .text-secondary, +.log-liste .log-wahl.active .text-warning { color: rgba(255, 255, 255, .75) !important; } diff --git a/index.php b/index.php index e6763e0..b7dd3ed 100644 --- a/index.php +++ b/index.php @@ -113,6 +113,8 @@ if (isset($_GET["addUser"])) { "scripts" => ["js/solar/weatherMQTT.js"]], "settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false, "scripts" => ["js/solar/settings.js"]], + "logs" => ["template" => "logs.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false, + "scripts" => ["js/solar/logs.js"]], ]; // Unbekannte oder fehlende Aktion faellt auf die Solar-Seite zurueck. diff --git a/js/solar/logs.js b/js/solar/logs.js new file mode 100644 index 0000000..5c44762 --- /dev/null +++ b/js/solar/logs.js @@ -0,0 +1,250 @@ +/* + * Protokollseite: die Logdateien der Hintergrundprozesse ansehen. + * + * Die Seite haelt nichts vor - jede Anfrage holt frisch die letzten N Zeilen + * vom Server. Das ist Absicht: ein Log wird von hinten gelesen, und wer + * blaettert, will ohnehin filtern statt scrollen. Der Server liest die Datei + * dafuer rueckwaerts, ein Blick in die 300-MB-Datei der Wallbox kostet + * deshalb so viel wie einer in die kleine des Weckers. + * + * Gezeichnet wird aus der Antwort, nicht aus dem DOM. Die einzige Sache, die + * ueber einen Aufruf hinaus bestehen bleibt, ist die zuletzt gewaehlte Datei + * im localStorage - damit man nach dem Neuladen dort weitermacht, wo man war. + */ + +(function () { + "use strict"; + + const liste = document.querySelector(".log-liste"); + if (!liste) return; // andere Seite + + const ausgabe = document.querySelector(".log-ausgabe"); + const hinweis = document.querySelector(".log-hinweis"); + const titel = document.querySelector(".log-titel"); + const standTxt = document.querySelector(".log-stand"); + const suche = document.querySelector(".log-suche"); + const level = document.querySelector(".log-level"); + const anzahl = document.querySelector(".log-anzahl"); + const folgen = document.querySelector(".log-folgen"); + + const SPEICHER = "logDateiWahl"; + // Direkt und nicht ueber window: die Seite deklariert den Ausgangsstand mit + // const, und das legt nichts auf window ab. + let dateien = (typeof logDateienStand !== "undefined" && Array.isArray(logDateienStand)) + ? logDateienStand : []; + let gewaehlt = localStorage.getItem(SPEICHER) || (dateien[0] && dateien[0].key) || "runner"; + let uhr = null; + let abbruch = null; // laufende Anfrage, siehe laden() + + // --- Kleinkram ---------------------------------------------------------- + + function escape(s) { + return String(s === null || s === undefined ? "" : s) + .replace(/&/g, "&").replace(//g, ">"); + } + + /** 345351905 -> "329,4 MB" */ + function groesse(bytes) { + if (!bytes) return "leer"; + const einheiten = ["B", "kB", "MB", "GB"]; + let i = 0, z = bytes; + while (z >= 1024 && i < einheiten.length - 1) { z /= 1024; i++; } + return (i === 0 ? z : z.toFixed(1).replace(".", ",")) + " " + einheiten[i]; + } + + /** + * "vor 3 Minuten". Wichtiger als der Zeitstempel selbst: eine Datei, die + * seit Tagen nicht gewachsen ist, gehoert angesehen. + */ + function alter(stand) { + if (!stand) return ""; + const sek = (Date.now() - new Date(stand.replace(" ", "T")).getTime()) / 1000; + if (sek < 90) return "gerade eben"; + if (sek < 5400) return "vor " + Math.round(sek / 60) + " min"; + if (sek < 172800) return "vor " + Math.round(sek / 3600) + " h"; + return "vor " + Math.round(sek / 86400) + " Tagen"; + } + + /** Ab wann eine Datei als eingeschlafen gilt - grob, nur zum Einfaerben. */ + function eingeschlafen(stand) { + if (!stand) return true; + return (Date.now() - new Date(stand.replace(" ", "T")).getTime()) > 24 * 3600 * 1000; + } + + // --- Dateiliste --------------------------------------------------------- + + function listeZeichnen() { + liste.innerHTML = dateien.map(function (d) { + const aktiv = d.key === gewaehlt ? " active" : ""; + let zustand; + if (!d.da) { + zustand = 'gibt es nicht'; + } else if (!d.lesbar) { + zustand = 'nicht lesbar'; + } else { + const farbe = eingeschlafen(d.stand) ? "text-warning" : "text-secondary"; + zustand = '' + escape(alter(d.stand)) + "" + + ' · ' + escape(groesse(d.groesse)) + ""; + } + return '"; + }).join(""); + } + + function zustandLaden() { + return fetch("ajax/logs.php?action=list", { cache: "no-store" }) + .then(function (r) { return r.json(); }) + .then(function (a) { + if (a.dateien) { dateien = a.dateien; listeZeichnen(); } + }) + .catch(function () { /* der naechste Versuch kommt bestimmt */ }); + } + + // --- Zeilen ------------------------------------------------------------- + + const KLASSE = { + DEBUG: "log-debug", + INFO: "log-info", + WARNING: "log-warning", + ERROR: "log-error", + CRITICAL: "log-error" + }; + + function zeilenZeichnen(antwort) { + const zeilen = antwort.zeilen || []; + if (!zeilen.length) { + // Eine leere Datei und ein Filter ohne Treffer sehen gleich aus, sind + // aber zwei verschiedene Befunde: das eine heisst "der Prozess hat noch + // nichts gesagt", das andere "such woanders". + const leer = (antwort.zustand || {}).groesse === 0; + ausgabe.innerHTML = '
' + + (leer ? "Die Datei ist leer – seit dem letzten Start hat der Prozess nichts gemeldet." + : "Keine Zeile passt.") + + "
"; + return; + } + + let letztesDatum = ""; + const stuecke = []; + zeilen.forEach(function (z) { + const zeit = z.t || ""; + const datum = zeit.slice(0, 10); + const uhrzeit = zeit.slice(11, 19); + + // Ein Tageswechsel bekommt eine eigene Zeile. Sonst steht das Datum + // 300-mal untereinander und die Uhrzeit geht darin unter. + if (datum && datum !== letztesDatum) { + letztesDatum = datum; + stuecke.push('
' + escape(datum.split("-").reverse().join(".")) + "
"); + } + + const klasse = KLASSE[z.l] || "log-info"; + stuecke.push( + '
' + + '' + escape(uhrzeit) + "" + + '' + escape(z.fortsetzung ? "" : z.l) + "" + + '' + escape(z.m) + "" + + "
" + ); + }); + ausgabe.innerHTML = stuecke.join(""); + ausgabe.scrollTop = ausgabe.scrollHeight; // ans Ende, da steht das Neueste + } + + function laden() { + // Eine Suche, die nichts findet, laesst den Server vier Megabyte lesen - + // das dauert spuerbar. Wer in der Zeit weitertippt, soll nicht auf die + // alte Antwort warten: die laufende Anfrage wird abgebrochen, die neue + // startet sofort. Eine blosse "laeuft gerade"-Sperre wuerde stattdessen + // die letzte Eingabe verschlucken, und die ist die, die zaehlt. + if (abbruch) abbruch.abort(); + abbruch = new AbortController(); + const meine = abbruch; + + hinweis.textContent = "sucht …"; + const p = new URLSearchParams({ + action: "read", + datei: gewaehlt, + anzahl: anzahl.value, + suche: suche.value.trim(), + level: level.value + }); + return fetch("ajax/logs.php?" + p.toString(), { cache: "no-store", signal: meine.signal }) + .then(function (r) { return r.json(); }) + .then(function (a) { + if (a.error) { + ausgabe.innerHTML = '
' + escape(a.error) + "
"; + hinweis.textContent = ""; + return; + } + const z = a.zustand || {}; + titel.textContent = z.titel || "Protokoll"; + standTxt.textContent = z.datei ? z.datei + " · " + groesse(z.groesse) + " · " + alter(z.stand) : ""; + + let text = (a.zeilen || []).length + " Zeilen"; + if (a.abgeschnitten) { + text += " · es wurden nur die letzten " + groesse(a.durchsucht) + + " der Datei durchsucht, ältere Treffer fehlen also"; + } + hinweis.textContent = text; + zeilenZeichnen(a); + }) + .catch(function (f) { + // Ein Abbruch ist kein Fehler - da tippt jemand weiter. + if (f.name === "AbortError") return; + hinweis.textContent = ""; + ausgabe.innerHTML = '
Nicht erreichbar: ' + escape(f.message) + "
"; + }) + .finally(function () { if (abbruch === meine) abbruch = null; }); + } + + // --- Bedienung ---------------------------------------------------------- + + liste.addEventListener("click", function (e) { + const knopf = e.target.closest(".log-wahl"); + if (!knopf) return; + gewaehlt = knopf.dataset.key; + localStorage.setItem(SPEICHER, gewaehlt); + listeZeichnen(); + laden(); + }); + + // Beim Tippen nicht bei jedem Anschlag fragen - das Lesen kostet auf der + // NAS genug, dass man es merkt. + let tippUhr = null; + suche.addEventListener("input", function () { + clearTimeout(tippUhr); + tippUhr = setTimeout(laden, 400); + }); + + level.addEventListener("change", laden); + anzahl.addEventListener("change", laden); + document.querySelector(".log-neu").addEventListener("click", function () { + zustandLaden(); + laden(); + }); + document.querySelector(".log-zustand-neu").addEventListener("click", zustandLaden); + + folgen.addEventListener("change", function () { + clearInterval(uhr); + if (folgen.checked) { + uhr = setInterval(function () { + // Im Hintergrundtab nicht nachladen: das Fenster sieht niemand, und + // die Anfrage laeuft trotzdem ueber die Platte. + if (!document.hidden) { zustandLaden(); laden(); } + }, 10000); + } + }); + + listeZeichnen(); + laden(); + // Der eingebettete Stand ist beim Aufschlagen schon ein paar Sekunden alt. + // Ein Nachfassen kostet nichts und macht aus "vor 18 min" die Wahrheit. + zustandLaden(); +})(); diff --git a/restricted/header.php b/restricted/header.php index 6482ab2..fe3cc64 100644 --- a/restricted/header.php +++ b/restricted/header.php @@ -183,13 +183,21 @@ if (!isset($_GET["floor"])) + sondern die Parameter dahinter. Genauso die + Protokolle - sie zeigen nicht das Haus, sondern + die Prozesse, die es steuern. --> + diff --git a/restricted/logdateien.php b/restricted/logdateien.php new file mode 100644 index 0000000..698f37b --- /dev/null +++ b/restricted/logdateien.php @@ -0,0 +1,271 @@ + [ + "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, + ]; +} diff --git a/restricted/logs.php b/restricted/logs.php new file mode 100644 index 0000000..5fcf521 --- /dev/null +++ b/restricted/logs.php @@ -0,0 +1,125 @@ +getMessage(); +} +?> + +
+ +
+
+
+
+

Protokolle

+
+
+
+
+ + +
+
+ +
Die Protokolle sind nicht erreichbar:
+ +
+ +
+
+
+

Prozesse

+
+ +
+
+
+
+
+
+
+ + +
+
+
+

+ Protokoll +

+
+ + +
+
+ +
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+ + +
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +