]> git.giorgioravera.it Git - network-manager.git/commitdiff
Initial version of log modu.le
authorGiorgio Ravera <giorgio.ravera@gmail.com>
Thu, 18 Jun 2026 16:02:43 +0000 (18:02 +0200)
committerGiorgio Ravera <giorgio.ravera@gmail.com>
Thu, 18 Jun 2026 16:02:43 +0000 (18:02 +0200)
backend/app.py
backend/routes/logs.py [new file with mode: 0644]
frontend/css/layout.css
frontend/index.html
frontend/js/logs.js [new file with mode: 0644]
frontend/js/services.js
frontend/js/settings.js
frontend/logs.html [new file with mode: 0644]
frontend/settings.html

index 5df07a2c7254aa774cdbf9d0fadbff002f377712..9d19221d360b8869a16e4910f9432780fc39fd14 100644 (file)
@@ -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 (file)
index 0000000..82cc22b
--- /dev/null
@@ -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))
index 53d17269a972ad88f4d76153934bd9b38cdf470b..d729c9762866ad2b79fcffb32c170bac7c2c4da9 100644 (file)
@@ -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;
+    }
 }
 
 /* ======================================
index 30a1ea56588b5e67b0e68129dd941beb2faede51..ce3ddad92afd48d6208c364552051b85e864fc39 100644 (file)
             </div>
 
             <!-- Logs -->
-            <a href="logs/index.html" class="tile text-decoration-none">
+            <a href="/logs" class="tile text-decoration-none">
                 <div class="tile-icon"><i class="bi bi-file-earmark-text"></i></div>
                 <h3>Logs</h3>
                 <p>Eventi e access log.</p>
diff --git a/frontend/js/logs.js b/frontend/js/logs.js
new file mode 100644 (file)
index 0000000..06b124e
--- /dev/null
@@ -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 =
+                `<span class="log-date">${date}</span>
+                <span class="log-level">${level}</span>
+                <span class="log-source">${source}</span>
+                <span class="log-msg">${msg}</span>`;
+
+            // 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 =
+                    `<i class="bi bi-file-text me-1"></i> ${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();
+            });
+
+        });
+}
index cd0531c5aeb9d6ff0c20e41f097a5c446c075f4a..f8fefabfeeed47294c99b6aa189e46b571e1f0ea 100644 (file)
@@ -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();
+}
index d0b734530f52b0eb319237d34d3dbf7c599e0ac8..498c3bd790a4c7c7b2f5157fd7d7ccdd36db4d8e 100644 (file)
@@ -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 (file)
index 0000000..1273f4a
--- /dev/null
@@ -0,0 +1,256 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="UTF-8">
+    <title>Network Manager</title>
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    <!-- Bootstrap 5.x CSS (CDN) -->
+    <link
+        href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
+        rel="stylesheet"
+        integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
+        crossorigin="anonymous"
+    >
+    <!-- Bootstrap Icons -->
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
+
+    <!-- Boostrap override -->
+    <link rel="stylesheet" href="css/variables.css">
+    <link rel="stylesheet" href="css/layout.css">
+</head>
+
+<body>
+    <!-- Topbar -->
+    <header class="topbar">
+        <div class="topbar-inner">
+            <a href="/home" class="logo text-decoration-none">
+                <svg width="30" height="30" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
+                    <circle cx="12" cy="4" r="2"></circle>
+                    <circle cx="4" cy="12" r="2"></circle>
+                    <circle cx="20" cy="12" r="2"></circle>
+                    <circle cx="12" cy="20" r="2"></circle>
+                    <line x1="12" y1="6" x2="12" y2="18" stroke="var(--accent)" stroke-width="2"></line>
+                    <line x1="6" y1="12" x2="18" y2="12" stroke="var(--accent)" stroke-width="2"></line>
+                </svg>
+                <span>Network Manager</span>
+            </a>
+
+            <nav class="navbar">
+                <!-- Hamburger -->
+                <button
+                    class="btn btn-primary d-md-none"
+                    type="button"
+                    data-bs-toggle="offcanvas"
+                    data-bs-target="#mobileMenu">
+                    <i class="bi bi-list fs-4"></i>
+                </button>
+
+                <!-- Desktop nav -->
+                <div class="d-none d-md-flex gap-2 ms-auto">
+                        <a href="/hosts"    class="btn btn-primary">       Hosts</a>
+                        <a href="/aliases"  class="btn btn-primary">       Alias</a>
+                        <a href="/leases"   class="btn btn-primary">       DHCP Leases</a>
+                        <a href="/devices"  class="btn btn-primary">       Devices</a>
+                        <a href="/logs"     class="btn btn-primary active">Logs</a>
+                        <a href="/settings" class="btn btn-primary">       Settings</a>
+                        <button class="btn btn-primary btn-logout">Logout</button>
+                </div>
+            </nav>
+
+            <!-- Mobile offcanvas -->
+            <div class="offcanvas offcanvas-end d-md-none" tabindex="-1" id="mobileMenu">
+
+                <div class="offcanvas-header">
+                    <h5 class="offcanvas-title">Network Manager</h5>
+
+                    <button
+                        type="button"
+                        class="btn-close"
+                        data-bs-dismiss="offcanvas">
+                    </button>
+                </div>
+
+                <div class="offcanvas-body">
+                    <div class="d-grid gap-2">
+                        <a href="/hosts"    class="btn btn-primary">       Hosts</a>
+                        <a href="/aliases"  class="btn btn-primary">       Alias</a>
+                        <a href="/leases"   class="btn btn-primary">       DHCP Leases</a>
+                        <a href="/devices"  class="btn btn-primary">       Devices</a>
+                        <a href="/logs"     class="btn btn-primary active">Logs</a>
+                        <a href="/settings" class="btn btn-primary">       Settings</a>
+                        <button class="btn btn-primary btn-logout">Logout</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </header>
+
+    <!-- Toast -->
+    <div id="toast" class="toast" role="status" aria-live="polite" aria-atomic="true"></div>
+
+    <!-- Toolbar / Section header -->
+    <section class="page-frame">
+        <div class="container-fluid p-0">
+            <div class="row g-2 align-items-center">
+
+                <!-- Title -->
+                <div class="col-auto col-md-auto">
+                    <h2 class="mb-0 d-flex align-items-center gap-2 lh-1">
+                        <span class="title-icon"><i class="bi bi-hdd-network"></i></span>
+                        <span class="section-title">System Logs</span>
+                    </h2>
+                </div>
+
+                <!-- Spacer -->
+                <div class="col"></div>
+
+                <!-- Bottoni -->
+                <div class="col-auto col-md-auto d-flex align-items-center gap-2 flex-nowrap">
+
+                    <!-- Live Flag -->
+                    <div class="gap-2">
+                        <input type="checkbox" id="liveToggle" checked> Live
+                    </div>
+
+                    <!-- LOG TYPE -->
+                    <div id="logTypeDropdown" class="dropdown gap-2">
+                        <button
+                            id="logTypeBtn"
+                            class="btn btn-primary dropdown-toggle"
+                            data-bs-toggle="dropdown">
+                            <i class="bi bi-file-text me-1"></i>
+                            App
+                        </button>
+                        <ul class="dropdown-menu">
+                            <li><a class="dropdown-item active" data-value="app" href="#">App</a></li>
+                            <li><a class="dropdown-item" data-value="access" href="#">Access</a></li>
+                            <li><a class="dropdown-item" data-value="dhcp" href="#">DHCP</a></li>
+                            <li><a class="dropdown-item" data-value="dns" href="#">DNS</a></li>
+                        </ul>
+                    </div>
+
+                    <!-- Refresh -->
+                    <button
+                        class="btn btn-primary d-none d-md-flex align-items-center gap-2 px-3"
+                        title="Refresh"
+                        aria-label="Refresh"
+                        data-action="refreshLogs">
+                        <i class="bi bi-arrow-repeat"></i>
+                        <span class="label">Refresh</span>
+                    </button>
+
+                    <!-- Separator -->
+                    <div class="vr mx-2 d-none d-md-block"></div>
+
+                    <!-- Reload DNS Desktop -->
+                    <button
+                        class="btn btn-primary d-none d-md-flex align-items-center gap-2 px-3"
+                        title="Reload DNS (BIND)"
+                        aria-label="Reload DNS"
+                        data-action="reloadDns">
+                        <i class="bi bi-arrow-repeat"></i>
+                        <span class="label">Reload DNS</span>
+                    </button>
+
+                    <!-- Reload DHCP Desktop -->
+                    <button
+                        class="btn btn-primary d-none d-md-flex align-items-center gap-2 px-3"
+                        title="Reload DHCP (Kea)"
+                        aria-label="Reload DHCP"
+                        data-action="reloadDhcp">
+                        <i class="bi bi-arrow-repeat"></i>
+                        <span class="label">Reload DHCP</span>
+                    </button>
+
+                    <!-- Separator -->
+                    <div class="vr mx-2 d-none d-md-block"></div>
+
+                    <!-- Restart Desktop -->
+                    <button
+                        class="btn btn-danger d-none d-md-flex align-items-center gap-2 px-3"
+                        title="Restart system"
+                        aria-label="Restart system"
+                        data-action="restartApp">
+                        <i class="bi bi-arrow-repeat"></i>
+                        <span class="label">Restart</span>
+                    </button>
+
+                    <!-- Mobile Dropdown -->
+                    <div class="dropdown d-md-none">
+                        <button
+                            class="btn btn-primary d-flex align-items-center px-3"
+                            type="button"
+                            data-bs-toggle="dropdown"
+                            aria-expanded="false">
+                            <i class="bi bi-three-dots-vertical"></i>
+                        </button>
+
+                        <ul class="dropdown-menu dropdown-menu-end shadow">
+
+                            <!-- Reload DNS Mobile -->
+                            <li class="px-2 py-1">
+                                <button
+                                    class="btn btn-primary w-100 d-flex align-items-center gap-2"
+                                    title="Reload DNS (BIND)"
+                                    aria-label="Reload DNS"
+                                    data-action="reloadDns">
+                                    <i class="bi bi-arrow-repeat"></i>
+                                    <span>Reload DNS</span>
+                                </button>
+                            </li>
+
+                            <!-- Reload DHCP Mobile -->
+                            <li class="px-2 py-1">
+                                <button
+                                    class="btn btn-primary w-100 d-flex align-items-center gap-2"
+                                    title="Reload DHCP (Kea)"
+                                    aria-label="Reload DHCP"
+                                    data-action="reloadDhcp">
+                                    <i class="bi bi-arrow-repeat"></i>
+                                    <span>Reload DHCP</span>
+                                </button>
+                            </li>
+
+                            <!-- Restart Mobile -->
+                            <li class="px-2 py-1">
+                                <button
+                                    class="btn btn-danger w-100 d-flex align-items-center gap-2"
+                                    title="Restart system"
+                                    aria-label="Restart system"
+                                    data-action="restartApp">
+                                    <i class="bi bi-arrow-repeat"></i>
+                                    <span class="label">Restart</span>
+                                </button>
+                            </li>
+                        </ul>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </section>
+
+    <!-- Logs viewer -->
+    <div id="logViewer" class="log-viewer mobile-scroll mb-0"></div>
+
+    <!-- Loader -->
+    <div id="loader" class="text-center my-3" style="display: none;">
+        <div class="spinner-border text-primary" role="status">
+            <span class="visually-hidden">Loading...</span>
+        </div>
+    </div>
+    <div id="devices-container"></div>
+
+    <!-- Modals -->
+    <div id="modals-container"></div>
+
+    <!-- Scripts -->
+    <script type="module" src="js/logs.js"></script>
+    <script type="module" src="js/session.js"></script>
+
+    <!-- Bootstrap JS -->
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
+            integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
+            crossorigin="anonymous"></script>
+</body>
+</html>
index dbf0e7563eb1b9032908a807507d73714e8f76e2..8e03d90aadcdd9c8f0e58d26f82f8effa66d662e 100644 (file)
                     <!-- Separator -->
                     <div class="vr mx-2 d-none d-md-block"></div>
 
-                    <!-- Restart -->
+                    <!-- Restart Desktop -->
                     <button
                         class="btn btn-danger d-none d-md-flex align-items-center gap-2 px-3"
                         title="Restart system"