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",
"children": {
"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_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)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog 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.edit": "Benutzer ändern (inkl. Löschen)",
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
@@ -333,6 +335,7 @@ NAV_ITEMS = [
]},
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"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_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
if key == "logs_live":
return user.can_view_live_log
if key == "logs_history":
return user.can_view_log_history
if key == "logs_activity":
return user.can_view_activity_log
if key == "logs_kea":
@@ -552,6 +557,14 @@ class User(UserMixin):
def can_view_live_log(self):
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
def can_view_activity_log(self):
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
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):
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht
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")
@login_required
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:
return "Keine Berechtigung.", 403
latest_log = _latest_log_file()
if not latest_log:
return "Keine Logfiles gefunden."
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()
except Exception as e:
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(latest_log)
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")
@login_required
def logs():
@@ -5736,16 +5934,21 @@ def logs():
latest_log = _latest_log_file()
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:
with open(latest_log, "r") as f:
log_content = f.read()
except Exception as e:
log_content = _tail_lines(latest_log)
total_lines = _count_lines(latest_log)
except OSError as e:
log_content = f"Fehler beim Lesen des Logs: {e}"
total_lines = None
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.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)
========================================================================== */
+53
View File
@@ -211,6 +211,59 @@
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 ---------------- */
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
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"/>',
"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"/>',
"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"/>',
"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"/>',
+20 -3
View File
@@ -8,18 +8,31 @@
<div class="section-head">
<div>
<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>
<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;" 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 id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
</div>
@@ -43,7 +56,7 @@ document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000;
function renderLog(text, logName) {
function renderLog(text, logName, totalLines) {
const box = document.getElementById("log-box");
box.innerHTML = "";
const lines = text.split("\n");
@@ -55,11 +68,15 @@ document.addEventListener("DOMContentLoaded", () => {
if (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() {
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));
}
+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 %}