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>
This commit is contained in:
2026-09-03 19:26:56 +02:00
co-authored by Claude Opus 5
parent abfa84dff8
commit cbc9353f0a
7 changed files with 777 additions and 1 deletions
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Endpunkt der Logseite.
*
* GET ?action=list Zustand aller bekannten Dateien
* GET ?action=read&datei=runner&... Zeilen einer Datei
*
* Parameter von read:
* datei Schluessel aus logDateien()
* anzahl wieviele Zeilen (1 bis 5000)
* suche Text, der vorkommen muss
* level Mindestlevel: INFO, WARNING, ERROR
*
* Ein reiner Verteiler - gelesen wird in restricted/logdateien.php.
*/
require_once("../helper.php");
require_once("../restricted/logdateien.php");
if (!checkLogin()) {
http_response_code(403);
exit;
}
function jsonAntwort($data, $code = 200)
{
http_response_code($code);
header("Content-Type: application/json; charset=utf-8");
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
exit;
}
$action = $_GET["action"] ?? "list";
try {
switch ($action) {
case "list":
$liste = [];
foreach (array_keys(logDateien()) as $key) {
$liste[] = logZustand($key);
}
jsonAntwort(["dateien" => $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);
}
+61
View File
@@ -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; }
+2
View File
@@ -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.
+250
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** 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 = '<span class="text-danger">gibt es nicht</span>';
} else if (!d.lesbar) {
zustand = '<span class="text-danger">nicht lesbar</span>';
} else {
const farbe = eingeschlafen(d.stand) ? "text-warning" : "text-secondary";
zustand = '<span class="' + farbe + '">' + escape(alter(d.stand)) + "</span>"
+ '<span class="text-secondary"> · ' + escape(groesse(d.groesse)) + "</span>";
}
return '<button type="button" class="list-group-item list-group-item-action log-wahl' + aktiv + '"'
+ ' data-key="' + escape(d.key) + '" title="' + escape(d.hinweis) + '">'
+ '<div class="d-flex align-items-center">'
+ '<i class="bi ' + escape(d.symbol) + ' me-2"></i>'
+ '<span class="flex-grow-1">' + escape(d.titel) + "</span>"
+ "</div>"
+ '<div class="small mt-1">' + zustand + "</div>"
+ "</button>";
}).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 = '<div class="text-secondary p-3">'
+ (leer ? "Die Datei ist leer seit dem letzten Start hat der Prozess nichts gemeldet."
: "Keine Zeile passt.")
+ "</div>";
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('<div class="log-tag">' + escape(datum.split("-").reverse().join(".")) + "</div>");
}
const klasse = KLASSE[z.l] || "log-info";
stuecke.push(
'<div class="log-zeile ' + klasse + (z.fortsetzung ? " log-fortsetzung" : "") + '">'
+ '<span class="log-zeit">' + escape(uhrzeit) + "</span>"
+ '<span class="log-level">' + escape(z.fortsetzung ? "" : z.l) + "</span>"
+ '<span class="log-text">' + escape(z.m) + "</span>"
+ "</div>"
);
});
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 = '<div class="alert alert-warning mb-0">' + escape(a.error) + "</div>";
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 = '<div class="alert alert-danger mb-0">Nicht erreichbar: ' + escape(f.message) + "</div>";
})
.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();
})();
+9 -1
View File
@@ -183,13 +183,21 @@ if (!isset($_GET["floor"]))
</ul>
</li>
<!-- Einstellungen stehen fuer sich: kein Dashboard,
sondern die Parameter dahinter. -->
sondern die Parameter dahinter. Genauso die
Protokolle - sie zeigen nicht das Haus, sondern
die Prozesse, die es steuern. -->
<li class="nav-item">
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
<i class="nav-icon bi bi-gear"></i>
<p>Einstellungen</p>
</a>
</li>
<li class="nav-item">
<a href="?action=logs" class="nav-link<?php if($_GET["action"]=="logs"){echo " active";} ?>">
<i class="nav-icon bi bi-journal-text"></i>
<p>Protokolle</p>
</a>
</li>
</ul>
<!--end::Sidebar Menu-->
</nav>
+271
View File
@@ -0,0 +1,271 @@
<?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,
];
}
+125
View File
@@ -0,0 +1,125 @@
<?php
/**
* Logseite: hineinsehen, was die Hintergrundprozesse melden.
*
* Links die Dateien mit ihrem Zustand, rechts die Zeilen. Der Zustand ist
* mehr als Schmuck: ein Prozess, dessen Datei seit Tagen nicht gewachsen
* ist, faellt hier auf, ohne dass man sie oeffnet.
*
* Gelesen wird ueber ajax/logs.php, gezeichnet in js/solar/logs.js. Die
* erste Liste steht schon im Dokument, damit die Seite nicht leer aufgeht
* und man beim langsamen Zugriff von unterwegs sofort sieht, was es gibt.
*/
require_once(__DIR__ . "/logdateien.php");
$dateien = [];
$fehler = "";
try {
foreach (array_keys(logDateien()) as $key) {
$dateien[] = logZustand($key);
}
} catch (Throwable $e) {
$fehler = $e->getMessage();
}
?>
<!--begin::App Main-->
<main class="app-main">
<!--begin::App Content Header-->
<div class="app-content-header">
<div class="container-fluid">
<div class="row">
<div class="col-sm-12">
<h3 class="mb-0">Protokolle</h3>
</div>
</div>
</div>
</div>
<!--end::App Content Header-->
<!--begin::App Content-->
<div class="app-content">
<div class="container-fluid">
<?php if ($fehler): ?>
<div class="alert alert-danger">Die Protokolle sind nicht erreichbar: <?= htmlspecialchars($fehler) ?></div>
<?php endif; ?>
<div class="row">
<!-- Dateiliste -->
<div class="col-12 col-lg-3">
<div class="card mb-4">
<div class="card-header pb-2 pt-2">
<h3 class="card-title"><i class="bi bi-journal-text me-2"></i>Prozesse</h3>
<div class="card-tools">
<button type="button" class="btn btn-tool log-zustand-neu" title="Zustand neu einlesen">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<div class="card-body p-0">
<div class="list-group list-group-flush log-liste"></div>
</div>
</div>
</div>
<!-- Zeilen -->
<div class="col-12 col-lg-9">
<div class="card mb-4">
<div class="card-header pb-2 pt-2">
<h3 class="card-title">
<i class="bi bi-terminal me-2"></i><span class="log-titel">Protokoll</span>
</h3>
<div class="card-tools">
<span class="log-stand small text-secondary me-2"></span>
<button type="button" class="btn btn-tool log-neu" title="Neu laden">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
<div class="card-body pb-2">
<div class="row g-2 align-items-center mb-3">
<div class="col-12 col-md-4">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="search" class="form-control log-suche" placeholder="Text enthält …">
</div>
</div>
<div class="col-6 col-md-3">
<select class="form-select form-select-sm log-level">
<option value="">alle Meldungen</option>
<option value="INFO">ab Info</option>
<option value="WARNING">ab Warnung</option>
<option value="ERROR">nur Fehler</option>
</select>
</div>
<div class="col-6 col-md-2">
<select class="form-select form-select-sm log-anzahl">
<option value="100">100 Zeilen</option>
<option value="300" selected>300 Zeilen</option>
<option value="1000">1000 Zeilen</option>
<option value="5000">5000 Zeilen</option>
</select>
</div>
<div class="col-12 col-md-3">
<div class="form-check form-switch mb-0">
<input class="form-check-input log-folgen" type="checkbox" id="logFolgen">
<label class="form-check-label small" for="logFolgen">alle 10 s nachladen</label>
</div>
</div>
</div>
<div class="log-hinweis small text-secondary mb-2"></div>
<div class="log-ausgabe"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!--end::App Content-->
</main>
<!--end::App Main-->
<script>
// Zustand der Dateien beim Seitenaufbau. Die Zeilen selbst holt das Skript
// nach - sie sind das Grosse, und welche Datei zuerst gezeigt wird, weiss
// erst der Browser (er merkt sich die zuletzt gewaehlte).
const logDateienStand = <?= json_encode($dateien, JSON_UNESCAPED_UNICODE) ?>;
</script>