# 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
# 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
--- /dev/null
+# 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))
.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;
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
================================ */
.table-responsive {
margin: 10px;
- width: calc(100% - 20px);
max-height: calc(100dvh - 9rem);
}
.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;
+ }
}
/* ======================================
</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>
--- /dev/null
+// 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();
+ });
+
+ });
+}
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();
+}
// -----------------------------
// Restart application
// -----------------------------
-async function restartApp(button) {
+async function handleRestartApp(button) {
const confirmed = await showConfirmModal("Restart the application?");
if (!confirmed) return;
edit: () => {
// handled by bootstrap modal show event
},
- // Reload DHCP
+ // Reload App
restartApp: async (e, el) => {
- await restartApp(el)
+ await handleRestartApp(el)
},
}
--- /dev/null
+<!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>
<!-- 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"