Compare commits
2
Commits
d285ec27a0
...
cbc9353f0a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbc9353f0a | ||
|
|
abfa84dff8 |
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, "<").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 = '<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();
|
||||
})();
|
||||
@@ -630,10 +630,15 @@ function saveAutomation($data)
|
||||
throw new RuntimeException("Automatik " . $id . " gibt es nicht mehr.");
|
||||
}
|
||||
|
||||
// changed wird ausdruecklich mitgeschrieben. Es haengt zwar an
|
||||
// ON UPDATE, das greift aber nur, wenn sich hier oben wirklich
|
||||
// eine Spalte aendert - wer nur eine Bedingung verstellt, laesst
|
||||
// Name, Stockwerk und Fenster ja unberuehrt. Der Runner sieht am
|
||||
// Zeitstempel, dass er sein Regelwerk neu laden muss.
|
||||
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
|
||||
window_from = ?, window_to = ?, weekdays = ?,
|
||||
on_vacation = ?, on_holiday = ?, force_once = ?,
|
||||
lockout_secs = ?
|
||||
lockout_secs = ?, changed = NOW()
|
||||
WHERE id = ?");
|
||||
$stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
|
||||
$weekdays, $onVacation, $onHoliday, $forceOnce, $lockout, $id);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user