Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
befc76819e | ||
|
|
99d4d3cd01 |
+11
-6
@@ -102,13 +102,18 @@ sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update
|
||||
sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed"
|
||||
|
||||
# ---- Log-Verzeichnis ----
|
||||
# Weltweit beschreibbar, da sowohl root (tesm.service/poe.sh) als auch
|
||||
# der i.d.R. unprivilegierte kea-dhcp4-server-Dienstbenutzer (_kea) hier
|
||||
# ihre jeweils eigene Log-Datei anlegen/schreiben müssen. Die eigentliche
|
||||
# logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt die App
|
||||
# selbst beim Start bzw. beim Speichern unter Systemeinstellungen → Logs.
|
||||
# NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
|
||||
# auch tesm-check.service (poe.sh) laufen als root, kein anderer
|
||||
# Dienstbenutzer schreibt hierher (Keas eigenes Log liegt separat unter
|
||||
# TESM_KEA_LOG_PATH, i.d.R. /var/log/kea/). Ein group-/world-writable
|
||||
# Verzeichnis lässt logrotate die Rotation aus Sicherheitsgründen
|
||||
# komplett verweigern ("insecure permissions") -- live reproduziert: bei
|
||||
# 0777 rotierte live.log über Wochen hinweg gar nicht mehr. Die
|
||||
# eigentliche logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt
|
||||
# die App selbst beim Start bzw. beim Speichern unter
|
||||
# Systemeinstellungen → Logs.
|
||||
sudo mkdir -p /var/log/tesm
|
||||
sudo chmod 777 /var/log/tesm
|
||||
sudo chmod 755 /var/log/tesm
|
||||
|
||||
# ---- App-Verzeichnis ----
|
||||
step "Deploying application to /srv/tesm"
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.4
|
||||
1.0.6
|
||||
|
||||
+225
-10
@@ -95,7 +95,13 @@ TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.
|
||||
LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm")
|
||||
try:
|
||||
os.makedirs(TESM_LOG_DIR, exist_ok=True)
|
||||
os.chmod(TESM_LOG_DIR, 0o777)
|
||||
# War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service,
|
||||
# tesm-check.service) als root lief. Ein world-writable Verzeichnis lässt
|
||||
# logrotate die Rotation aber aus Sicherheitsgründen komplett verweigern
|
||||
# ("insecure permissions") -- live reproduziert: ohne Rotation blieb
|
||||
# live.log unbegrenzt wachsen, siehe auch "su root root" in
|
||||
# _write_logrotate_config als zusätzliche Absicherung.
|
||||
os.chmod(TESM_LOG_DIR, 0o755)
|
||||
except OSError:
|
||||
pass
|
||||
SWITCH_DEFAULT_SSH_PORT = 22
|
||||
@@ -209,6 +215,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 +285,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 +341,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 +377,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 +563,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")
|
||||
@@ -1170,7 +1189,12 @@ def _write_logrotate_config():
|
||||
Log-Reopen-Signal (SIGHUP o.ä.) implementiert — copytruncate
|
||||
funktioniert ohne jede Kooperation des schreibenden Prozesses (Kea
|
||||
eingeschlossen: logrotate läuft als root, unterliegt also nicht Keas
|
||||
eigenem AppArmor-Profil)."""
|
||||
eigenem AppArmor-Profil). "su root root" ist eine explizite Absicherung
|
||||
dagegen, dass logrotate die Rotation wegen "insecure permissions"
|
||||
verweigert, sobald TESM_LOG_DIR aus irgendeinem Grund wieder
|
||||
gruppen-/world-writable wird (live reproduziert: bei 0o777 rotierte
|
||||
logrotate live.log gar nicht mehr, siehe auch os.chmod(TESM_LOG_DIR)
|
||||
weiter oben, das den eigentlichen Regelfall -- 0o755 -- durchsetzt)."""
|
||||
interval = get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL)
|
||||
if interval not in LOG_ROTATION_INTERVALS:
|
||||
interval = LOG_ROTATION_DEFAULT_INTERVAL
|
||||
@@ -1188,6 +1212,7 @@ def _write_logrotate_config():
|
||||
f" missingok\n"
|
||||
f" notifempty\n"
|
||||
f" copytruncate\n"
|
||||
f" su root root\n"
|
||||
f"}}\n"
|
||||
)
|
||||
try:
|
||||
@@ -1828,6 +1853,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, …),
|
||||
@@ -4964,7 +5089,7 @@ def _write_client_update_error_log(name, exit_code, lines):
|
||||
auch ohne offenes Browser-Fenster im Nachhinein auffindbar bleibt."""
|
||||
try:
|
||||
os.makedirs(TESM_CLIENT_UPDATE_LOG_DIR, exist_ok=True)
|
||||
os.chmod(TESM_CLIENT_UPDATE_LOG_DIR, 0o777)
|
||||
os.chmod(TESM_CLIENT_UPDATE_LOG_DIR, 0o755)
|
||||
safe_name = secure_filename(name) or "unbenannt"
|
||||
path = os.path.join(TESM_CLIENT_UPDATE_LOG_DIR, f"{safe_name}.error")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
@@ -5711,21 +5836,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 +5946,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
========================================================================== */
|
||||
|
||||
@@ -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>×</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/
|
||||
|
||||
@@ -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"/>',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user