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>
251 lines
9.7 KiB
JavaScript
251 lines
9.7 KiB
JavaScript
/*
|
||
* 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();
|
||
})();
|