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 ''
+ + ''
+ + ' '
+ + '' + escape(d.titel) + " "
+ + "
"
+ + '' + zustand + "
"
+ + " ";
+ }).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. -->
">
Einstellungen
+
+ ">
+
+ Protokolle
+
+
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();
+}
+?>
+
+
+
+
+
+
+
+
+
+
Die Protokolle sind nicht erreichbar: = htmlspecialchars($fehler) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ alle Meldungen
+ ab Info
+ ab Warnung
+ nur Fehler
+
+
+
+
+ 100 Zeilen
+ 300 Zeilen
+ 1000 Zeilen
+ 5000 Zeilen
+
+
+
+
+
+ alle 10 s nachladen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+