Compare commits
2
Commits
| 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; }
|
.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"]],
|
"scripts" => ["js/solar/weatherMQTT.js"]],
|
||||||
"settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
"settings" => ["template" => "settings.php", "mqtt" => false, "charts" => false, "meteogram" => false, "leaflet" => false,
|
||||||
"scripts" => ["js/solar/settings.js"]],
|
"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.
|
// 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.");
|
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 = ?,
|
$stmt = $db->prepare("UPDATE automations SET name = ?, floor = ?, enabled = ?,
|
||||||
window_from = ?, window_to = ?, weekdays = ?,
|
window_from = ?, window_to = ?, weekdays = ?,
|
||||||
on_vacation = ?, on_holiday = ?, force_once = ?,
|
on_vacation = ?, on_holiday = ?, force_once = ?,
|
||||||
lockout_secs = ?
|
lockout_secs = ?, changed = NOW()
|
||||||
WHERE id = ?");
|
WHERE id = ?");
|
||||||
$stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
|
$stmt->bind_param("ssissiiiiii", $name, $floor, $enabled, $windowFrom, $windowTo,
|
||||||
$weekdays, $onVacation, $onHoliday, $forceOnce, $lockout, $id);
|
$weekdays, $onVacation, $onHoliday, $forceOnce, $lockout, $id);
|
||||||
|
|||||||
@@ -183,13 +183,21 @@ if (!isset($_GET["floor"]))
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<!-- Einstellungen stehen fuer sich: kein Dashboard,
|
<!-- 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">
|
<li class="nav-item">
|
||||||
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
|
<a href="?action=settings" class="nav-link<?php if($_GET["action"]=="settings"){echo " active";} ?>">
|
||||||
<i class="nav-icon bi bi-gear"></i>
|
<i class="nav-icon bi bi-gear"></i>
|
||||||
<p>Einstellungen</p>
|
<p>Einstellungen</p>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
<!--end::Sidebar Menu-->
|
<!--end::Sidebar Menu-->
|
||||||
</nav>
|
</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