From 241089d9368f67ac5e978c51bf5288b07c3bbee9 Mon Sep 17 00:00:00 2001 From: Giorgio Ravera Date: Thu, 18 Jun 2026 18:02:43 +0200 Subject: [PATCH] Initial version of log modu.le --- backend/app.py | 22 ++- backend/routes/logs.py | 87 +++++++++ frontend/css/layout.css | 76 +++++++- frontend/index.html | 2 +- frontend/js/logs.js | 407 ++++++++++++++++++++++++++++++++++++++++ frontend/js/services.js | 13 ++ frontend/js/settings.js | 6 +- frontend/logs.html | 256 +++++++++++++++++++++++++ frontend/settings.html | 2 +- 9 files changed, 852 insertions(+), 19 deletions(-) create mode 100644 backend/routes/logs.py create mode 100644 frontend/js/logs.js create mode 100644 frontend/logs.html diff --git a/backend/app.py b/backend/app.py index 5df07a2..9d19221 100644 --- a/backend/app.py +++ b/backend/app.py @@ -10,15 +10,16 @@ from typing import Callable # Import Routers from backend.routes.system import router as system_router -from backend.routes.devices import router as devices_router -from backend.routes.backup import router as backup_router -from backend.routes.certificates import router as certificates_router from backend.routes.health import router as health_router from backend.routes.login import router as login_router -from backend.routes.hosts import router as hosts_router -from backend.routes.aliases import router as aliases_router from backend.routes.dns import router as dns_router from backend.routes.dhcp import router as dhcp_router +from backend.routes.hosts import router as hosts_router +from backend.routes.aliases import router as aliases_router +from backend.routes.devices import router as devices_router +from backend.routes.certificates import router as certificates_router +from backend.routes.backup import router as backup_router +from backend.routes.logs import router as logs_router from backend.routes.settings import router as settings_router # Import Security @@ -214,15 +215,16 @@ def create_app() -> FastAPI: # Routers app.include_router(system_router) - app.include_router(backup_router) - app.include_router(devices_router) - app.include_router(certificates_router) app.include_router(health_router) app.include_router(login_router) - app.include_router(hosts_router) - app.include_router(aliases_router) app.include_router(dns_router) app.include_router(dhcp_router) + app.include_router(hosts_router) + app.include_router(aliases_router) + app.include_router(devices_router) + app.include_router(certificates_router) + app.include_router(backup_router) + app.include_router(logs_router) app.include_router(settings_router) # CORS diff --git a/backend/routes/logs.py b/backend/routes/logs.py new file mode 100644 index 0000000..82cc22b --- /dev/null +++ b/backend/routes/logs.py @@ -0,0 +1,87 @@ +# backend/routes/hosts.py + +# import standard modules +from fastapi import APIRouter, Request, HTTPException, Query +from fastapi.responses import FileResponse, PlainTextResponse +from pathlib import Path + +# Import local modules + +# Import Settings +from backend.settings.settings import settings +# Import Logging +from backend.log.log import get_logger + +# Logger initialization +logger = get_logger(__name__) + +LOG_FILES = { + "app": settings.LOG_FILE, + "access": settings.LOG_ACCESS_FILE, + #"dhcp": Path("/var/log/kea/kea-dhcp4.log"), + #"dns": Path("/var/log/named/named.log"), +} + +# Create Router +router = APIRouter() + +# --------------------------------------------------------- +# FRONTEND PATHS (absolute paths inside Docker) +# --------------------------------------------------------- +# Hosts page +@router.get("/logs") +def hosts_page(request: Request): + return FileResponse(settings.FRONTEND_PATH / "logs.html") + +# Serve hosts.js +@router.get("/js/logs.js") +def hosts_js(): + return FileResponse(settings.FRONTEND_PATH / "js/logs.js") + +# --------------------------------------------------------- +# Internal: File tail +# --------------------------------------------------------- +def tail_file(path: Path, lines: int = 200) -> str: + """ + Legge le ultime N righe in modo efficiente + """ + with open(path, "rb") as f: + f.seek(0, 2) # vai a fine file + file_size = f.tell() + + buffer = bytearray() + pointer = file_size - 1 + line_count = 0 + + while pointer >= 0 and line_count < lines: + f.seek(pointer) + byte = f.read(1) + + if byte == b"\n": + line_count += 1 + + buffer.extend(byte) + pointer -= 1 + + buffer.reverse() + return buffer.decode(errors="ignore") + +# --------------------------------------------------------- +# Get Logs +# --------------------------------------------------------- +@router.get("/api/logs", response_class=PlainTextResponse) +def get_logs( + type: str = Query("app", pattern="^(app|access|dhcp|dns)$"), + lines: int = Query(200, ge=10, le=5000) +): + log_path = LOG_FILES.get(type) + + if not log_path or not log_path.exists(): + logger.error(f"Log file not found: {type}") + raise HTTPException(status_code=404, detail=f"Log file not found: {type}") + + try: + return tail_file(log_path, lines) + except Exception as e: + logger.exception("Error reading log") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend/css/layout.css b/frontend/css/layout.css index 53d1726..d729c97 100644 --- a/frontend/css/layout.css +++ b/frontend/css/layout.css @@ -363,10 +363,8 @@ i.bi { .table-responsive { position: relative; margin: 20px; - width: calc(100% - 40px); max-height: calc(100vh - 174px); - overflow-x: auto; - overflow-y: auto; + overflow: auto; border-radius: 10px; box-shadow: 0 3px 6px rgba(0,0,0,0.18); background-color: #fff; @@ -645,6 +643,62 @@ td.actions { to { transform: rotate(360deg); } } +/* ================================ + Log Viewer + ================================ */ +.log-viewer { + position: relative; + margin: 20px; + height: calc(100vh - 174px); + overflow: auto; + box-shadow: 0 3px 6px rgba(0,0,0,0.18); + border: 1px solid var(--border-light); + border-radius: .375rem; + background: #111; + color: #ddd; + font-family: monospace; + font-size: 0.85rem; + scrollbar-width: thin; + scrollbar-color: #9cdcfe transparent; + -webkit-overflow-scrolling: touch; +} + +.log-row { + display: grid; + grid-template-columns: 180px 50px 150px minmax(0, 1fr); + gap: 8px; + align-items: start; + cursor: pointer; + transition: background-color .15s ease; + user-select: none; + -webkit-user-select: none; +} + +.log-row:hover { + background-color: rgba(255,255,255,.05); +} + +.log-row.selected { + background-color: rgba(156, 220, 254, 0.15); + border-left: 3px solid #9cdcfe; +} + +.log-date, +.log-level, +.log-source { + white-space: nowrap; +} + +.log-message { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* log levels */ +.log-info { color: #9cdcfe; } +.log-warn { color: #f9e79f; } +.log-error { color: #ff6b6b; } + /* ================================ Responsive ================================ */ @@ -674,7 +728,6 @@ td.actions { .table-responsive { margin: 10px; - width: calc(100% - 20px); max-height: calc(100dvh - 9rem); } @@ -702,6 +755,21 @@ td.actions { .btn .bi { margin-right: 0; } + + .log-viewer { + margin: 10px; + height: calc(100dvh - 9rem); + font-size: 0.65rem; + } + + .log-row { + grid-template-columns: 150px 1fr; + } + + .log-level, + .log-source { + display: none; + } } /* ====================================== diff --git a/frontend/index.html b/frontend/index.html index 30a1ea5..ce3ddad 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -174,7 +174,7 @@ - +

Logs

Eventi e access log.

diff --git a/frontend/js/logs.js b/frontend/js/logs.js new file mode 100644 index 0000000..06b124e --- /dev/null +++ b/frontend/js/logs.js @@ -0,0 +1,407 @@ +// Import common js +import { loadModals, showToast, showConfirmModal, handleReload } from './common.js'; +// Import services +import { serviceReloadDNS, serviceReloadDHCP, serviceRestartApp, serviceIsAlive, serviceGetLogs } from './services.js'; + +// ----------------------------- +// State variables +// ----------------------------- +const logViewer = document.getElementById("logViewer"); +const loader = document.getElementById("loader"); +let selectedLogType = "app"; +let live = true; +let requestId = 0; +let loading = false; +let selectedRows = new Set(); +let lastSelectedRowId = null; + +// ----------------------------- +// load logs from backend +// ----------------------------- +async function loadLogs() { + const type = selectedLogType; + const previousScroll = logViewer.scrollTop; + const previousHeight = logViewer.scrollHeight; + + const isNearBottom = + previousHeight - previousScroll <= logViewer.clientHeight + 50; + + // return in case of concurrent requests + if (loading) return; + loading = true; + const currentRequest = ++requestId; + + loader.style.display = "block"; + + try { + const data = await serviceGetLogs(type); + + // Check concurrency + if (currentRequest !== requestId) return; + + // Fetch Logs + renderLogs(data, isNearBottom, previousScroll, previousHeight); + + } catch (err) { + logViewer.textContent = err.message || "Errore loading logs"; + } finally { + loader.style.display = "none"; + loading = false; + } +} + +// ----------------------------- +// shows logs in the table +// ----------------------------- +function renderLogs(text, isNearBottom, previousScroll, previousHeight) { + + const lines = text.split("\n"); + const fragment = document.createDocumentFragment(); + + lines.forEach((line, index) => { + const div = document.createElement("div"); + + if (line.includes("ERROR")) div.classList.add("log-error"); + else if (line.includes("WARN")) div.classList.add("log-warn"); + else div.classList.add("log-info"); + + //div.textContent = line; + + const match = line.match( + /^(\S+)\s+(INFO|WARN|ERROR)\s+\[([^\]]+)\]\s+(.*)$/ + ); + + if (match) { + const [, date, level, source, msg] = match; + + // define rowID as combination of the log + const rowId = `${date}|${level}|${source}|${msg}`; + + div.dataset.index = index; + div.dataset.rowId = rowId; + + div.classList.add("log-row"); + + // single click select effect + div.addEventListener("click", (e) => { + + // Shift = interval + if (e.shiftKey && lastSelectedRowId !== null) { + + const rows = Array.from( + document.querySelectorAll(".log-row") + ); + + const currentIndex = Number(div.dataset.index); + + const lastRow = rows.find( + r => r.dataset.rowId === lastSelectedRowId + ); + + if (lastRow) { + + const lastIndex = Number(lastRow.dataset.index); + + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + + for (let i = start; i <= end; i++) { + + const row = rows[i]; + row.classList.add("selected"); + + selectedRows.add(row.dataset.rowId); + } + } + + return; + } + + // Ctrl = add/remove + if (e.ctrlKey) { + + if (selectedRows.has(rowId)) { + selectedRows.delete(rowId); + div.classList.remove("selected"); + } else { + selectedRows.add(rowId); + div.classList.add("selected"); + } + + } else { + + // Single line + selectedRows.clear(); + + document + .querySelectorAll(".log-row.selected") + .forEach(r => r.classList.remove("selected")); + + selectedRows.add(rowId); + div.classList.add("selected"); + } + + lastSelectedRowId = rowId; + }); + + // double click to copy + div.addEventListener("dblclick", async () => { + await navigator.clipboard.writeText(line); + }); + + div.innerHTML = + `${date} + ${level} + ${source} + ${msg}`; + + // restore old selection + if (selectedRows.has(rowId)) { + div.classList.add("selected"); + } + } + + fragment.appendChild(div); + }); + + // update view + logViewer.innerHTML = ""; + logViewer.appendChild(fragment); + + + if (isNearBottom) { + // "tail -f" + logViewer.scrollTop = logViewer.scrollHeight; + } else { + // restore old scroll position + const newHeight = logViewer.scrollHeight; + if (newHeight < previousHeight) { + // log rotation o reset + logViewer.scrollTop = 0; + } else { + const diff = newHeight - previousHeight; + logViewer.scrollTop = previousScroll + diff; + } + } +} + +// ----------------------------- +// update dropdown menu +// ----------------------------- +function updateDropdownUI(value) { + const button = document.getElementById("logTypeBtn"); + + document + .querySelectorAll('#logTypeDropdown .dropdown-item') + .forEach(item => { + + const active = item.dataset.value === value; + + item.classList.toggle('active', active); + + if (active) { + button.innerHTML = + ` ${item.textContent}`; + } + }); +} + +// ----------------------------- +// Polling to check if the system restarted properly +// ----------------------------- +function startReconnectPolling(button, originalHtmlButton) { + + if (!button) return; + + const maxAttempts = 30; // 1 minuto + let attempts = 0; + + const interval = setInterval(async () => { + + attempts++; + if (attempts > maxAttempts) { + clearInterval(interval); + showToast("Server did not come back online", false); + button.innerHTML = originalHtmlButton; + button.disabled = false; + return; + } + + try { + // prova endpoint leggero + const isUp = await serviceIsAlive(); + + if (isUp) { + + clearInterval(interval); + + showToast("Application is back online", true); + + setTimeout(() => location.reload(), 500); + + } + } catch (err) { + console.log("Waiting for server..."); + } + + }, 2000); // check every 2 seconds +} + +// ----------------------------- +// Restart application +// ----------------------------- +async function handleRestartApp(button) { + const confirmed = await showConfirmModal("Restart the application?"); + if (!confirmed) return; + + const originalHtmlButton = button.innerHTML; + + const ok = await handleReload( + button, + serviceRestartApp, + "Application is restarting...", + "Error restarting application", + "Restarting...", + true + ); + + if (ok !== false) { + startReconnectPolling(button, originalHtmlButton); + } +} + +// ----------------------------- +// Action Handlers +// ----------------------------- +const actionHandlers = { + // Refresh Logs + refreshLogs: (e, el) => { + loadLogs(); + }, + // Copy Logs + //copyLogs(e, el) => { + // copySelectedLogs(); + //}, + // Reload DNS + reloadDns: async (e, el) => { + await handleReload( + el, + serviceReloadDNS, + "DNS reload successfully", + "Error reloading DNS", + "Reloading DNS..." + ); + }, + // Reload DHCP + reloadDhcp: async (e, el) => { + await handleReload( + el, + serviceReloadDHCP, + "DHCP reload successfully", + "Error reloading DHCP", + "Reloading DHCP..." + ); + }, + // Reload DHCP + restartApp: async (e, el) => { + await handleRestartApp(el) + }, +}; + +// ----------------------------- +// DOMContentLoaded: bootstrap app +// ----------------------------- +document.addEventListener("DOMContentLoaded", async () => { + initApp(); + loadLogs(); +}); + +// ----------------------------- +// APP INIT +// ----------------------------- +async function initApp() { + + // Load modals (Bootstrap 5 requires JS initialization for dynamic content) + try { + await loadModals(); + } catch (err) { + console.error(err?.message || "Error loading modals"); + showToast(err?.message || "Error loading modals", false); + } + + initEvents(); + initDropdown(); + + // auto refresh (tipo tail -f) + setInterval(() => { + if (live) loadLogs(); + }, 5000); +} + +// ----------------------------- +// GLOBAL EVENTS INIT +// ----------------------------- +function initEvents() { + + document.addEventListener('click', handleActionClick); + + const liveToggle = document.getElementById("liveToggle"); + if (liveToggle) { + liveToggle.addEventListener("change", (e) => { + live = e.target.checked; + }); + } +} + +// ----------------------------- +// CLICK (DATA-ACTION) +// ----------------------------- +async function handleActionClick(e) { + const el = e.target.closest('[data-action]'); + if (!el) return; + + const action = el.dataset.action; + const handler = actionHandlers[action]; + if (!handler) return; + + // Execute handler + try { + await handler(e, el); + } catch (err) { + console.error(err?.message || 'Action error'); + showToast(err?.message || 'Action error', false); + } +} + +// ----------------------------- +// Init dropdown menu +// ----------------------------- +function initDropdown() { + + // restore previouse selection + const saved = localStorage.getItem("logType"); + if (saved) { + selectedLogType = saved; + } + + // Update Dropdown menu + updateDropdownUI(selectedLogType); + + document.querySelectorAll('#logTypeDropdown .dropdown-item') + .forEach(item => { + + item.addEventListener('click', e => { + + e.preventDefault(); + + selectedLogType = item.dataset.value; + + updateDropdownUI(selectedLogType); + + localStorage.setItem("logType", selectedLogType); + + loadLogs(); + }); + + }); +} diff --git a/frontend/js/services.js b/frontend/js/services.js index cd0531c..f8fefab 100644 --- a/frontend/js/services.js +++ b/frontend/js/services.js @@ -382,3 +382,16 @@ export async function serviceRestartApp(key) { return data?.message ? { message: data.message } : true; } + +// ----------------------------- +// Get Logs +// ----------------------------- +export async function serviceGetLogs(type) { + const res = await fetch(`/api/logs?type=${type}`); + + if (!res.ok) { + throw new Error(await res.text()); + } + + return await res.text(); +} diff --git a/frontend/js/settings.js b/frontend/js/settings.js index d0b7345..498c3bd 100644 --- a/frontend/js/settings.js +++ b/frontend/js/settings.js @@ -193,7 +193,7 @@ function startReconnectPolling(button, originalHtmlButton) { // ----------------------------- // Restart application // ----------------------------- -async function restartApp(button) { +async function handleRestartApp(button) { const confirmed = await showConfirmModal("Restart the application?"); if (!confirmed) return; @@ -693,9 +693,9 @@ const actionHandlers = { edit: () => { // handled by bootstrap modal show event }, - // Reload DHCP + // Reload App restartApp: async (e, el) => { - await restartApp(el) + await handleRestartApp(el) }, } diff --git a/frontend/logs.html b/frontend/logs.html new file mode 100644 index 0000000..1273f4a --- /dev/null +++ b/frontend/logs.html @@ -0,0 +1,256 @@ + + + + + Network Manager + + + + + + + + + + + + + + +
+
+ + + + + +
+ +
+
Network Manager
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+

+ + System Logs +

+
+ + +
+ + +
+ + +
+ Live +
+ + + + + + + + +
+ + + + + + + + +
+ + + + + + +
+
+
+
+ + +
+ + + +
+ + +
+ + + + + + + + + diff --git a/frontend/settings.html b/frontend/settings.html index dbf0e75..8e03d90 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -154,7 +154,7 @@
- +