Compare commits

..
1 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 99d4d3cd01 Live-Log: 300-Zeilen-Tail, RAW-Popup und Log-Verlauf (History) mit eigenem Recht
- /logs und /get_log liefern nur noch die letzten 300 Zeilen (_tail_lines,
  effizientes Rueckwaerts-Lesen ohne komplette Datei einzulesen) statt der
  kompletten live.log -- behebt Ladezeit-Probleme auf groesseren Umgebungen.
- Neuer Endpoint /logs/live/raw liefert das komplette Live-Log fuer ein
  Popup im Stil des bestehenden 'Ungespeicherte Aenderungen'-Modals
  (window.openRawLogModal in app.js, lazy nachgeladen erst bei Klick).
- Neue Unterseite 'Verlauf' (/logs/history, /logs/history/raw) fuer aeltere,
  von logrotate rotierte Kopien des Live-Logs (live.log.1, .2, ...),
  auswaehlbar per Dropdown, ebenfalls mit 300-Zeilen-Tail + RAW-Popup.
- Eigenes Recht logs_history.view (Kind von logs_group, direkt unter Live
  einsortiert) statt an logs_live.view gekoppelt -- Live-Status sehen und
  durch alte Logs blaettern sind bewusst getrennte Rechte.
- Dateiname-Validierung gegen Path-Traversal in _resolve_log_history_file
  (striktes Regex + Verzeichnis-Check + Existenzpruefung).
- VERSION 1.0.4 -> 1.0.5.

Live getestet auf Testsystem-Update-Restart (192.168.82.51): Tail-Anzeige,
RAW-Popup, Verlauf-Auswahl inkl. echter live.log.1, Permission-Matrix
(Gruppen-Seite) sowie Rechtetrennung (Nutzer mit nur logs_live.view sieht
/logs, aber weder Verlauf-Link noch Zugriff auf /logs/history bzw.
/logs/history/raw) und Path-Traversal-Abwehr allesamt verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 10:53:56 +02:00
7 changed files with 388 additions and 15 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.4 1.0.5
+210 -7
View File
@@ -209,6 +209,7 @@ PERMISSIONS = {
"view_key": "logs_group.view", "view_key": "logs_group.view",
"children": { "children": {
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}}, "logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
"logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view"}},
"logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}}, "logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}},
"logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}}, "logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}},
}, },
@@ -278,6 +279,7 @@ PERMISSION_LABELS = {
"papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)", "papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen", "logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
"logs_kea.view": "Kea-DHCP-Log lesen", "logs_kea.view": "Kea-DHCP-Log lesen",
"logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs)",
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen", "users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)", "users.edit": "Benutzer ändern (inkl. Löschen)",
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
@@ -333,6 +335,7 @@ NAV_ITEMS = [
]}, ]},
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [ {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"}, {"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"},
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"}, {"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"},
{"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"}, {"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"},
]}, ]},
@@ -368,6 +371,8 @@ def _nav_key_visible(key, user):
return user.can_view_settings_ldap return user.can_view_settings_ldap
if key == "logs_live": if key == "logs_live":
return user.can_view_live_log return user.can_view_live_log
if key == "logs_history":
return user.can_view_log_history
if key == "logs_activity": if key == "logs_activity":
return user.can_view_activity_log return user.can_view_activity_log
if key == "logs_kea": if key == "logs_kea":
@@ -552,6 +557,14 @@ class User(UserMixin):
def can_view_live_log(self): def can_view_live_log(self):
return self.has_permission("logs_live.view") return self.has_permission("logs_live.view")
@property
def can_view_log_history(self):
"""Ältere, durch logrotate rotierte Kopien des Live-Logs (Seite
Verlauf) -- bewusst eigenes Recht statt an logs_live.view gekoppelt,
damit z.B. ein Nutzer den aktuellen Live-Status sehen darf, ohne
automatisch auch durch ältere Logstände blättern zu können."""
return self.has_permission("logs_history.view")
@property @property
def can_view_activity_log(self): def can_view_activity_log(self):
return self.has_permission("logs_activity.view") return self.has_permission("logs_activity.view")
@@ -1828,6 +1841,106 @@ def _latest_log_file():
return TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None return TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None
LIVE_LOG_TAIL_LINES = 300
def _tail_lines(path, n=LIVE_LOG_TAIL_LINES, chunk_size=65536):
"""Liest effizient nur die letzten n Zeilen einer Datei, ohne sie
komplett einzulesen -- sucht dazu vom Dateiende rückwärts in Blöcken,
bis n Zeilenumbrüche gefunden wurden oder der Dateianfang erreicht ist.
Auf größeren Umgebungen (viele Geräte, kurzes Prüfintervall) kann
live.log zwischen zwei logrotate-Läufen mehrere MB groß werden; ein
komplettes Einlesen bei jedem Seitenaufruf/Live-Poll hat dort spürbare
Ladezeiten verursacht -- das war der eigentliche Auslöser dieser
Funktion."""
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
remaining = f.tell()
data = b""
while remaining > 0 and data.count(b"\n") <= n:
read_size = min(chunk_size, remaining)
remaining -= read_size
f.seek(remaining)
data = f.read(read_size) + data
text = data.decode("utf-8", errors="replace")
lines = text.split("\n")
return "\n".join(lines[-n:]) if len(lines) > n else text
def _count_lines(path):
"""Schnelle Zeilenzählung über rohe Byte-Chunks (nur Zählen von \\n,
kein Aufbau einer Python-Zeilenliste) -- für die "letzte n von insgesamt
X Zeilen"-Anzeige, ohne den Performance-Vorteil von _tail_lines wieder
durch ein volles Einlesen zunichte zu machen."""
try:
count = 0
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
count += chunk.count(b"\n")
return count
except OSError:
return None
def _format_log_size(num_bytes):
size = float(num_bytes)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
_LOG_HISTORY_FILE_RE = re.compile(r"^live\.log\.([1-9][0-9]*)$")
def _live_log_history_files():
"""Ältere, von logrotate rotierte Kopien des Live-Logs (live.log.1 =
zuletzt rotiert, .2 der Lauf davor, usw. -- copytruncate ohne compress,
siehe _write_logrotate_config, daher reine Textdateien ohne .gz),
sortiert von neu (1) nach alt."""
results = []
try:
entries = os.listdir(TESM_LOG_DIR)
except OSError:
return results
for entry in entries:
m = _LOG_HISTORY_FILE_RE.match(entry)
if not m:
continue
full_path = os.path.join(TESM_LOG_DIR, entry)
if not os.path.isfile(full_path):
continue
try:
mtime = os.path.getmtime(full_path)
size = os.path.getsize(full_path)
except OSError:
continue
results.append({
"filename": entry,
"generation": int(m.group(1)),
"mtime_str": datetime.fromtimestamp(mtime).strftime("%d.%m.%Y %H:%M"),
"size_str": _format_log_size(size),
})
results.sort(key=lambda r: r["generation"])
return results
def _resolve_log_history_file(filename):
"""Validiert einen vom Client übergebenen Dateinamen (?file=...) gegen
die tatsächlich vorhandenen rotierten Live-Log-Kopien in TESM_LOG_DIR --
verhindert Path-Traversal bzw. beliebiges Datei-Lesen über den
Query-Parameter. Gibt den vollen Pfad oder None zurück."""
if not filename or not _LOG_HISTORY_FILE_RE.match(filename):
return None
full_path = os.path.join(TESM_LOG_DIR, filename)
if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(TESM_LOG_DIR):
return None
if not os.path.isfile(full_path):
return None
return full_path
def get_last_seen(dev_name: str): def get_last_seen(dev_name: str):
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht """Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht
bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, ), bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, ),
@@ -5711,21 +5824,106 @@ def _run_ssh_reboot(host, port, username, password, timeout=15):
@app.route("/get_log") @app.route("/get_log")
@login_required @login_required
def get_log(): def get_log():
"""Wird vom Live-Log-Poll (Intervall-Timer + manueller Aktualisieren-
Button) aufgerufen -- liefert bewusst nur die letzten LIVE_LOG_TAIL_LINES
Zeilen statt der kompletten Datei (siehe _tail_lines), damit dieser
wiederkehrende Abruf auf größeren Umgebungen keine spürbaren Ladezeiten
mehr verursacht. Das komplette Log bleibt über get_log_raw erreichbar."""
if not current_user.can_view_live_log: if not current_user.can_view_live_log:
return "Keine Berechtigung.", 403 return "Keine Berechtigung.", 403
latest_log = _latest_log_file() latest_log = _latest_log_file()
if not latest_log: if not latest_log:
return "Keine Logfiles gefunden." return "Keine Logfiles gefunden."
try: try:
with open(latest_log, "r") as f: content = _tail_lines(latest_log)
total_lines = _count_lines(latest_log)
except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}"
total_lines = None
response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(latest_log)
if total_lines is not None:
response.headers["X-Total-Lines"] = str(total_lines)
return response
@app.route("/logs/live/raw")
@login_required
def get_log_raw():
"""Komplettes, unbearbeitetes Live-Log für das RAW-Popup -- bewusst ein
eigener Endpoint statt eines Query-Parameters an /get_log, damit der
normale Live-Poll (jeder Nutzer mit logs_live.view, in jedem
Prüfintervall) unter keinen Umständen versehentlich die komplette Datei
lädt. Wird nur einmalig auf explizite Nutzeraktion (Button-Klick)
nachgeladen."""
if not current_user.can_view_live_log:
return "Keine Berechtigung.", 403
latest_log = _latest_log_file()
if not latest_log:
return "Keine Logfiles gefunden."
try:
with open(latest_log, "r", encoding="utf-8", errors="replace") as f:
content = f.read() content = f.read()
except Exception as e: except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}" content = f"Fehler beim Lesen des Logs: {e}"
response = app.response_class(content, mimetype="text/plain") response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(latest_log) response.headers["X-Log-Name"] = os.path.basename(latest_log)
return response return response
@app.route("/logs/history")
@login_required
def logs_history():
"""Verlauf: ältere, von logrotate rotierte Kopien des Live-Logs, als
eigene Unterseite von Live erreichbar (eigenes Recht logs_history.view,
siehe User.can_view_log_history) -- bewusst getrennt vom laufenden
Live-Poll, damit das Durchblättern alter Logstände nicht denselben
Performance-Pfad wie die Live-Ansicht belastet."""
if not current_user.can_view_log_history:
flash("Keine Berechtigung, den Log-Verlauf einzusehen.", "danger")
return redirect(url_for("index"))
files = _live_log_history_files()
requested = request.args.get("file", "")
selected_path = _resolve_log_history_file(requested) if requested else None
selected_name = requested if selected_path else None
if not selected_path and files:
selected_name = files[0]["filename"]
selected_path = os.path.join(TESM_LOG_DIR, selected_name)
log_content = None
total_lines = None
if selected_path:
try:
log_content = _tail_lines(selected_path)
total_lines = _count_lines(selected_path)
except OSError as e:
log_content = f"Fehler beim Lesen des Logs: {e}"
return render_template(
"logs_history.html", files=files, selected_name=selected_name,
log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
)
@app.route("/logs/history/raw")
@login_required
def logs_history_raw():
if not current_user.can_view_log_history:
return "Keine Berechtigung.", 403
path = _resolve_log_history_file(request.args.get("file", ""))
if not path:
return "Unbekannte oder ungültige Log-Datei.", 404
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}"
response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(path)
return response
@app.route("/logs") @app.route("/logs")
@login_required @login_required
def logs(): def logs():
@@ -5736,16 +5934,21 @@ def logs():
latest_log = _latest_log_file() latest_log = _latest_log_file()
if not latest_log: if not latest_log:
return render_template("logs.html", log_content="Keine Logfiles gefunden.", interval=interval) return render_template(
"logs.html", log_content="Keine Logfiles gefunden.", interval=interval,
total_lines=None, tail_lines=LIVE_LOG_TAIL_LINES,
)
try: try:
with open(latest_log, "r") as f: log_content = _tail_lines(latest_log)
log_content = f.read() total_lines = _count_lines(latest_log)
except Exception as e: except OSError as e:
log_content = f"Fehler beim Lesen des Logs: {e}" log_content = f"Fehler beim Lesen des Logs: {e}"
total_lines = None
return render_template( return render_template(
"logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval "logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval,
total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
) )
+15
View File
@@ -1104,6 +1104,21 @@ select {
.log-line.sep { color: #3a4050; } .log-line.sep { color: #3a4050; }
.log-line.restart { color: #ffb454; } .log-line.restart { color: #ffb454; }
.raw-log-content {
flex: 1;
overflow: auto;
margin: 0;
padding: 14px 18px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
color: #c7ccd6;
background: #0a0c10;
}
[data-theme="light"] .raw-log-content { background: #0e1116; }
/* ========================================================================== /* ==========================================================================
Permission checklists (Gruppen) Permission checklists (Gruppen)
========================================================================== */ ========================================================================== */
+53
View File
@@ -211,6 +211,59 @@
openModal("save-discard-modal"); openModal("save-discard-modal");
}; };
/* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */
/* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte
Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur
breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette
Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch
nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem
(unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */
function ensureRawLogModal() {
if (document.getElementById("raw-log-modal")) return;
const html = `
<div class="modal-overlay" id="raw-log-modal">
<div class="modal" style="max-width:min(1100px, 92vw); height:85vh;">
<div class="modal-header">
<h3 id="raw-log-modal-title">Komplettes Log (RAW)</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body" style="padding:0; display:flex; flex-direction:column; flex:1; min-height:0;">
<pre id="raw-log-modal-content" class="raw-log-content"></pre>
</div>
<div class="modal-footer">
<span class="text-faint mono" id="raw-log-modal-meta" style="margin-right:auto; font-size:11.5px;"></span>
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("raw-log-modal").addEventListener("click", (e) => {
if (e.target.id === "raw-log-modal") closeModal(e.target);
});
}
window.openRawLogModal = function (url, title) {
if (!url) return;
ensureRawLogModal();
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
const content = document.getElementById("raw-log-modal-content");
const meta = document.getElementById("raw-log-modal-meta");
content.textContent = "Lade …";
meta.textContent = "";
openModal("raw-log-modal");
fetch(url)
.then((r) => {
const logName = r.headers.get("X-Log-Name");
if (logName) meta.textContent = logName;
return r.text();
})
.then((text) => { content.textContent = text; })
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
};
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */ /* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten /* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/ Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
+1
View File
@@ -19,6 +19,7 @@
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>', "key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>', "terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>', "history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>', "sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>', "logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>', "gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
+24 -7
View File
@@ -8,18 +8,31 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Live-Log</h2> <h2 style="font-size:16px;">Live-Log</h2>
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline).</div> <div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline). Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
</div>
<div class="flex gap-2">
{% if current_user.can_view_log_history %}
<a href="{{ url_for('logs_history') }}" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
Verlauf
</a>
{% endif %}
<button type="button" class="btn btn-secondary" onclick="openRawLogModal('{{ url_for('get_log_raw') }}', 'Komplettes Live-Log (RAW)')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
Komplettes Log (RAW)
</button>
<button id="refresh-btn" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Aktualisieren
</button>
</div> </div>
<button id="refresh-btn" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Aktualisieren
</button>
</div> </div>
<div class="log-shell"> <div class="log-shell">
<div class="log-toolbar"> <div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div> <div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span> <span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
<span class="text-faint mono" style="font-size:11.5px;" id="log-line-info">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %}</span>
</div> </div>
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div> <div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
</div> </div>
@@ -43,7 +56,7 @@ document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ global_check_interval | int }}; const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000; const intervalMilliseconds = intervalMinutes * 60 * 1000;
function renderLog(text, logName) { function renderLog(text, logName, totalLines) {
const box = document.getElementById("log-box"); const box = document.getElementById("log-box");
box.innerHTML = ""; box.innerHTML = "";
const lines = text.split("\n"); const lines = text.split("\n");
@@ -55,11 +68,15 @@ document.addEventListener("DOMContentLoaded", () => {
if (logName) { if (logName) {
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; }); document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
} }
const info = document.getElementById("log-line-info");
if (info && totalLines) {
info.textContent = "letzte {{ tail_lines }} von " + totalLines + " Zeilen";
}
} }
function fetchLog() { function fetchLog() {
fetch("{{ url_for('get_log') }}") fetch("{{ url_for('get_log') }}")
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name")))) .then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"), r.headers.get("X-Total-Lines"))))
.catch(err => console.error(err)); .catch(err => console.error(err));
} }
+84
View File
@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Verlauf{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ selected_name or "kein Archiv-Log" }}</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Log-Verlauf</h2>
<div class="hint">Ältere, von logrotate rotierte Kopien des Live-Logs — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar.</div>
</div>
<div class="flex gap-2">
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Zurück zur Live-Ansicht
</a>
<button type="button" class="btn btn-secondary"
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_name or '' }}')"
{% if not selected_name %}disabled{% endif %}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
Komplettes Log (RAW)
</button>
</div>
</div>
{% if not files %}
<p class="text-faint" style="font-size:12.5px;">
Noch keine rotierten Live-Logs vorhanden — sobald logrotate das aktuelle
<a href="{{ url_for('logs') }}" style="color:var(--accent-strong); font-weight:600;">Live-Log</a>
zum ersten Mal rotiert (siehe Rotations-Intervall unter Systemeinstellungen), erscheinen ältere Stände hier.
</p>
{% else %}
<div class="flex gap-2" style="align-items:center; margin-bottom:14px;">
<label for="history-file-select" class="text-faint" style="font-size:12.5px; font-weight:600;">Archiv-Stand:</label>
<select id="history-file-select" onchange="window.location.href=this.value;" class="mono" style="max-width:420px;">
{% for f in files %}
<option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}>
{{ f.filename }} — rotiert am {{ f.mtime_str }} ({{ f.size_str }})
</option>
{% endfor %}
</select>
</div>
<div class="log-shell">
<div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;">{{ selected_name }}</span>
<span class="text-faint mono" style="font-size:11.5px;">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %}</span>
</div>
<div id="log-box">{{ log_content or "" }}</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function colorizeLine(line) {
let cls = "";
if (line.includes(" ist erreichbar!")) cls = "online";
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
else if (line.startsWith("----")) cls = "sep";
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
const span = document.createElement("span");
span.className = "log-line" + (cls ? " " + cls : "");
span.textContent = line;
return span;
}
document.addEventListener("DOMContentLoaded", () => {
const box = document.getElementById("log-box");
if (!box) return;
const text = box.textContent;
box.innerHTML = "";
const lines = text.split("\n");
lines.forEach((line, i) => {
box.appendChild(colorizeLine(line));
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
});
box.scrollTop = box.scrollHeight;
});
</script>
{% endblock %}