From: Giorgio Ravera Date: Fri, 2 Jan 2026 19:10:08 +0000 (+0100) Subject: First commit X-Git-Tag: v0.0.1~55 X-Git-Url: http://git.giorgioravera.it/?a=commitdiff_plain;h=ce5ae68fca694607aafdce1cb9db3c346241a5d2;p=network-manager.git First commit --- ce5ae68fca694607aafdce1cb9db3c346241a5d2 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..229f7e6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Dockerfile +FROM python:3.12-slim + +WORKDIR /app + +# Install dependencies +RUN pip install --no-cache-dir fastapi uvicorn[standard] + +# Copy backend and frontend +COPY backend/ /app/backend/ +COPY frontend/ /app/frontend/ + +# Default environment variables +ENV DB_PATH=/data/database.db +ENV APP_PORT=8000 + +# Expose the port dynamically (Docker ignores env here but it's good documentation) +EXPOSE ${APP_PORT} + +# Use the env var in the startup command +CMD ["sh", "-c", "uvicorn backend.main:app --host 0.0.0.0 --port ${APP_PORT}"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..50a477a --- /dev/null +++ b/backend/db.py @@ -0,0 +1,87 @@ +import sqlite3 +import os +import ipaddress + +DB_PATH = os.environ.get("DB_PATH", "/app/database.db") + +# ----------------------------- +# Connect to the database +# ----------------------------- +def get_db(): + conn = sqlite3.connect(DB_PATH, timeout=5) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL;") + conn.execute("PRAGMA synchronous=NORMAL;") + return conn + +# ----------------------------- +# SELECT ALL HOSTS +# ----------------------------- +def get_hosts(): + conn = get_db() + cur = conn.execute("SELECT * FROM hosts ORDER BY name") + rows = cur.fetchall() + conn.close() + return [dict(r) for r in rows] + +# ----------------------------- +# SELECT SINGLE HOST +# ----------------------------- +def get_host(host_id: int): + conn = get_db() + cur = conn.execute("SELECT * FROM hosts WHERE id = ?", (host_id,)) + row = cur.fetchone() + conn.close() + return dict(row) if row else None + +# ----------------------------- +# INSERT HOST +# ----------------------------- +def add_host(data: dict): + conn = get_db() + cur = conn.execute( + "INSERT INTO hosts (name, ipv4, ipv6, mac, note, ssl_enabled) VALUES (?, ?, ?, ?, ?, ?)", + ( + data["name"], + data.get("ipv4"), + data.get("ipv6"), + data.get("mac"), + data.get("note"), + data.get("ssl_enabled", 0) + ) + ) + conn.commit() + last_id = cur.lastrowid + conn.close() + return last_id + +# ----------------------------- +# UPDATE HOST +# ----------------------------- +def update_host(host_id: int, data: dict): + conn = get_db() + conn.execute( + "UPDATE hosts SET name=?, ipv4=?, ipv6=?, mac=?, note=?, ssl_enabled=? WHERE id=?", + ( + data["name"], + data.get("ipv4"), + data.get("ipv6"), + data.get("mac"), + data.get("note"), + data.get("ssl_enabled", 0), + host_id + ) + ) + conn.commit() + conn.close() + return True + +# ----------------------------- +# DELETE HOST +# ----------------------------- +def delete_host(host_id: int): + conn = get_db() + conn.execute("DELETE FROM hosts WHERE id = ?", (host_id,)) + conn.commit() + conn.close() + return True diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..ac2f18f --- /dev/null +++ b/backend/main.py @@ -0,0 +1,112 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +import os +import ipaddress + +# Import models +from backend.db import ( + get_hosts, + get_host, + add_host, + update_host, + delete_host +) + +app = FastAPI() + +# Allow frontend JS to call the API +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# --------------------------------------------------------- +# FRONTEND PATHS (absolute paths inside Docker) +# --------------------------------------------------------- + +FRONTEND_DIR = "/app/frontend" + +# Homepage +@app.get("/") +def index(): + return FileResponse(os.path.join(FRONTEND_DIR, "index.html")) + +# Serve style.css +@app.get("/style.css") +def css(): + return FileResponse(os.path.join(FRONTEND_DIR, "style.css")) + +# Serve app.js +@app.get("/app.js") +def js(): + return FileResponse(os.path.join(FRONTEND_DIR, "app.js")) + +# --------------------------------------------------------- +# API ENDPOINTS +# --------------------------------------------------------- + +@app.get("/api/hosts") +def api_get_hosts(): + return get_hosts() + +@app.post("/api/hosts") +def api_add_host(data: dict): + name = data.get("name", "").strip() + ipv4 = data.get("ipv4") + ipv6 = data.get("ipv6") + + # Check input + if not name: + return {"error": "Name is required"} + + # Validate IPv4 + if ipv4: + try: + ipaddress.IPv4Address(ipv4) + except: + return {"error": "Invalid IPv4 format"} + + # Validate IPv6 + if ipv6: + try: + ipaddress.IPv6Address(ipv6) + except: + return {"error": "Invalid IPv6 format"} + + return {"id": add_host(data)} + +@app.get("/api/hosts/{host_id}") +def api_get_host(host_id: int): + return get_host(host_id) or {} + +@app.put("/api/hosts/{host_id}") +def api_update_host(host_id: int, data: dict): + name = data.get("name", "").strip() + ipv4 = data.get("ipv4") + ipv6 = data.get("ipv6") + + if not name: + return {"error": "Name is required"} + + if ipv4: + try: + ipaddress.IPv4Address(ipv4) + except: + return {"error": "Invalid IPv4 format"} + + if ipv6: + try: + ipaddress.IPv6Address(ipv6) + except: + return {"error": "Invalid IPv6 format"} + + update_host(host_id, data) + return {"status": "ok"} + +@app.delete("/api/hosts/{host_id}") +def api_delete_host(host_id: int): + delete_host(host_id) + return {"status": "ok"} diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..3b8107c --- /dev/null +++ b/backend/models.py @@ -0,0 +1,20 @@ +import sqlite3 +import os + +DB_PATH = os.environ.get("DB_PATH", "/app/database.db") + +def init_db(): + conn = sqlite3.connect(DB_PATH) + conn.execute(""" + CREATE TABLE IF NOT EXISTS hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + ipv4 TEXT, + ipv6 TEXT, + mac TEXT, + note TEXT, + ssl_enabled INTEGER NOT NULL DEFAULT 0 + ) + """) + conn.commit() + conn.close() diff --git a/create_db.sh b/create_db.sh new file mode 100755 index 0000000..0fa1f66 --- /dev/null +++ b/create_db.sh @@ -0,0 +1,112 @@ +#!/bin/bash +set -euo pipefail + +DB_FILE="database.db" +RESET=0 +DOMAIN="example.com" +PUBLIC_IP="127.0.0.1" + +# ================================ +# Parse arguments +# ================================ +while [[ $# -gt 0 ]]; do + case "$1" in + --reset) + RESET=1 + shift + ;; + --domain) + DOMAIN="$2" + shift 2 + ;; + --public-ip) + PUBLIC_IP="$2" + shift 2 + ;; + *) + echo "Unknown argument: $1" + exit 1 + ;; + esac +done + +# ================================ +# Reset database if requested +# ================================ +if [[ $RESET -eq 1 && -f "$DB_FILE" ]]; then + echo "[*] Removing existing database..." + rm -f "$DB_FILE" +fi + +# ================================ +# Skip creation if DB already exists +# ================================ +if [[ -f "$DB_FILE" ]]; then + echo "[✓] Database already exists. Nothing to do." + exit 0 +fi + +echo "[*] Creating database: $DB_FILE" + +# ================================ +# Create DB with dynamic settings +# ================================ +sqlite3 "$DB_FILE" < { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${h.name} + ${h.ipv4 || ""} + ${h.ipv6 || ""} + ${h.mac || ""} + ${h.note || ""} + ${h.ssl_enabled ? "✔" : ""} + + + + + + + + + + + + + + `; + tbody.appendChild(tr); + }); +} + +// ----------------------------- +// OPEN POPUP IN EDIT MODE +// ----------------------------- +async function editHost(id) { + const res = await fetch(`/api/hosts/${id}`); + const host = await res.json(); + + // Store the ID of the host being edited + editingHostId = id; + + // Pre-fill the form fields + document.getElementById("hostName").value = host.name; + document.getElementById("hostIPv4").value = host.ipv4 || ""; + document.getElementById("hostIPv6").value = host.ipv6 || ""; + document.getElementById("hostMAC").value = host.mac || ""; + document.getElementById("hostNote").value = host.note || ""; + document.getElementById("hostSSL").checked = host.ssl_enabled === 1; + + document.getElementById("addHostModal").style.display = "flex"; +} + +// ----------------------------- +// OPEN POPUP IN CREATE MODE +// ----------------------------- +function openAddHostModal() { + editingHostId = null; // Reset edit mode + + // Clear all fields + document.getElementById("hostName").value = ""; + document.getElementById("hostIPv4").value = ""; + document.getElementById("hostIPv6").value = ""; + document.getElementById("hostMAC").value = ""; + document.getElementById("hostNote").value = ""; + document.getElementById("hostSSL").checked = false; + + document.getElementById("addHostModal").style.display = "flex"; +} + +// ----------------------------- +// CLOSE POPUP +// ----------------------------- +function closeAddHostModal() { + editingHostId = null; // Always reset edit mode + document.getElementById("addHostModal").style.display = "none"; +} + +// ----------------------------- +// SAVE HOST (CREATE OR UPDATE) +// ----------------------------- +async function saveHost() { + // Validate required fields + if (!document.getElementById("hostName").value.trim()) { + showToast("Name is required", false); + return; // stop here, do NOT send the request + } + // Validate IP format + if (!isValidIP(document.getElementById("hostIPv4").value)) { + showToast("Invalid IPv4 format", false); + return; + } + if (!isValidIP(document.getElementById("hostIPv6").value)) { + showToast("Invalid IPv6 format", false); + return; + } + + const payload = { + name: document.getElementById("hostName").value, + ipv4: document.getElementById("hostIPv4").value, + ipv6: document.getElementById("hostIPv6").value, + mac: document.getElementById("hostMAC").value, + note: document.getElementById("hostNote").value, + ssl_enabled: document.getElementById("hostSSL").checked ? 1 : 0 + }; + + try { + if (editingHostId !== null) { + // UPDATE EXISTING HOST + await fetch(`/api/hosts/${editingHostId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + showToast("Host updated successfully"); + } else { + // CREATE NEW HOST + await fetch("/api/hosts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + + showToast("Host added successfully"); + } + + closeAddHostModal(); + loadHosts(); + + } catch (err) { + console.error(err); + showToast("Error while saving host", false); + } +} + +// ----------------------------- +// DELETE HOST +// ----------------------------- +async function deleteHost(id) { + try { + const res = await fetch(`/api/hosts/${id}`, { method: "DELETE" }); + + if (!res.ok) { + throw new Error("Delete failed"); + } + + showToast("Host removed successfully"); + + } catch (err) { + console.error(err); + showToast("Error while removing host", false); + } + + loadHosts(); +} + +// ----------------------------- +// Display a temporary notification message +// ----------------------------- +function showToast(message, success = true) { + const toast = document.getElementById("toast"); + toast.textContent = message; + + toast.style.background = success ? "#28a745" : "#d9534f"; // green / red + + toast.classList.add("show"); + + setTimeout(() => { + toast.classList.remove("show"); + }, 2500); +} + +// ----------------------------- +// filter hosts in the table +// ----------------------------- +function filterHosts() { + const query = document.getElementById("searchInput").value.toLowerCase(); + const rows = document.querySelectorAll("#hosts-table tbody tr"); + + rows.forEach(row => { + const text = row.textContent.toLowerCase(); + row.style.display = text.includes(query) ? "" : "none"; + }); +} + +// ----------------------------- +// Clear search on ESC key +// ----------------------------- +function clearSearch() { + const input = document.getElementById("searchInput"); + input.value = ""; + input.blur(); + loadHosts(); +} + +// ----------------------------- +// Sort the table by column +// ----------------------------- +function sortTable(colIndex) { + const table = document.getElementById("hosts-table"); + const tbody = table.querySelector("tbody"); + const rows = Array.from(tbody.querySelectorAll("tr")); + const headers = table.querySelectorAll("th .sort-arrow"); + + // Toggle direction + sortDirection[colIndex] = !sortDirection[colIndex]; + const direction = sortDirection[colIndex] ? 1 : -1; + + // Reset all arrows + headers.forEach(h => h.textContent = ""); + + // Set arrow for current column + headers[colIndex].textContent = direction === 1 ? "▲" : "▼"; + + rows.sort((a, b) => { + const A = a.children[colIndex].innerText.toLowerCase(); + const B = b.children[colIndex].innerText.toLowerCase(); + + // Numeric sort if both values are numbers + const numA = parseFloat(A); + const numB = parseFloat(B); + + if (!isNaN(numA) && !isNaN(numB)) { + return (numA - numB) * direction; + } + + return A.localeCompare(B) * direction; + }); + + rows.forEach(row => tbody.appendChild(row)); +} + +// ----------------------------- +// Reset sorting arrows and directions +// ----------------------------- +function resetSorting() { + // Svuota tutte le direzioni salvate + sortDirection = {}; + + // Rimuove tutte le frecce dalle colonne + const arrows = document.querySelectorAll("th .sort-arrow"); + arrows.forEach(a => a.textContent = ""); +} + +// ----------------------------- +// INITIAL TABLE LOAD +// ----------------------------- +loadHosts(); +document.getElementById("searchInput").value = ""; + +//document.getElementById("searchInput").addEventListener("keydown", (e) => { +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + resetSorting(); + clearSearch(); + } +}); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..dcc2e97 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,93 @@ + + + + + Network Manager + + + + +
+ +
+ +
+ +
+
+

🖧 Host List

+ + + + + +
+
+ + + + + + + + + + + + + + +
Name IPv4 IPv6 MAC Note SSL Actions
+ + + + + + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..e4c72d4 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,302 @@ +/* ================================ + Global color variables (pfSense style) + ================================ */ +:root { + --accent: #4da3ff; + --accent-hover: #1f8bff; + + --bg-dark: #111; + --bg-light: #f9f9f9; + --bg-frame: #e8e8e8; + + --text-light: #e6e6e6; + --text-dark: #222; + + --border-light: #ccc; +} + +/* ================================ + Global layout + ================================ */ +body { + font-family: sans-serif; + margin: 20px; + padding-top: 60px; /* space for fixed topbar */ + background-color: var(--bg-light); +} + +/* ================================ + Topbar (pfSense full-width header) + ================================ */ +.topbar { + width: 100%; + background: var(--bg-dark); + padding: 14px 26px; + display: flex; + align-items: center; + border-bottom: 3px solid var(--accent); + position: fixed; + top: 0; + left: 0; + z-index: 1000; +} + +.logo { + display: flex; + align-items: center; + gap: 14px; +} + +.logo span { + font-size: 1.6rem; + font-weight: 600; + color: var(--text-light); + letter-spacing: 0.5px; +} + +.logo svg { + filter: drop-shadow(0 0 2px rgba(0,0,0,0.6)); +} + +/* ================================ + Page frame (section header) + ================================ */ +.page-frame { + background-color: var(--bg-frame); + border-left: 4px solid var(--accent); + padding: 6px 14px; + margin-bottom: 25px; + box-shadow: 0 3px 6px rgba(0,0,0,0.18); +} + +.page-frame h2 { + margin: 0; + line-height: 1.2; +} + +.section-title { + font-size: 1.2rem; + font-weight: 600; + color: var(--text-dark); +} + +.frame-row { + display: flex; + align-items: center; + gap: 12px; +} + +.frame-row h2 { + margin-right: auto; +} + +/* ================================ + Typography + ================================ */ +h1 { + margin-bottom: 20px; +} + +/* ================================ + Table styling + ================================ */ +table { + border-collapse: collapse; + width: 100%; + background-color: white; + box-shadow: 0 3px 6px rgba(0,0,0,0.18); +} + +th, td { + border: 1px solid var(--border-light); + padding: 10px 12px; + text-align: left; + vertical-align: middle; +} + +th { + background-color: #eee; + font-weight: bold; + cursor: pointer; + user-select: none; + position: relative; +} + +th:hover::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background: var(--accent); +} + +.sort-arrow { + margin-left: 6px; + font-size: 12px; + opacity: 0.6; + display: inline-block; + width: 12px; /* prevents column shifting */ + text-align: center; +} + +/* ================================ + Action icons column + ================================ */ +td.actions { + white-space: nowrap; + text-align: left; +} + +.actions span { + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 4px; + border-radius: 4px; + transition: background 0.2s ease; + margin-right: 8px; +} + +.actions span:hover { + background-color: #e0f0ff; +} + +/* ================================ + Add-host button (pfSense style) + ================================ */ +.add-host-btn { + background-color: var(--accent); + color: white; + border: none; + padding: 6px 14px; + font-size: 0.95rem; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + transition: background 0.2s ease; + box-shadow: 0 2px 4px rgba(0,0,0,0.15); +} + +.add-host-btn:hover { + background-color: var(--accent-hover); +} + +/* ================================ + Search bar + ================================ */ +.search-bar { + width: 250px; + padding: 8px 12px; + border: 1px solid var(--border-light); + border-radius: 6px; + font-size: 14px; +} + +/* ================================ + Toast notification + ================================ */ +.toast { + position: fixed; + top: 20px; + right: 20px; + background: #333; + color: white; + padding: 12px 18px; + border-radius: 6px; + opacity: 0; + pointer-events: none; + transition: opacity 0.4s ease; + font-size: 14px; + z-index: 9999; +} + +.toast.show { + opacity: 1; +} + +/* ================================ + Modal overlay + ================================ */ +.modal { + display: none; + position: fixed; + z-index: 2000; + inset: 0; + background: rgba(0,0,0,0.45); + justify-content: center; + align-items: center; +} + +/* ================================ + Modal window + ================================ */ +.modal-content { + background: #f4f4f4; + padding: 20px 24px; + border-left: 4px solid var(--accent); + border-radius: 6px; + width: 320px; + box-shadow: 0 4px 12px rgba(0,0,0,0.25); +} + +.modal-content h3 { + margin: 0 0 14px 0; + font-size: 1.2rem; + color: var(--text-dark); +} + +.modal-content label { + display: block; + margin-top: 10px; + font-weight: 600; + color: #333; +} + +.modal-content input[type="text"] { + width: 100%; + padding: 6px; + margin-top: 4px; + border: 1px solid #bbb; + border-radius: 4px; +} + +/* ================================ + Modal buttons + ================================ */ +.modal-buttons { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 18px; +} + +.cancel-btn { + background: #ccc; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; +} + +.save-btn { + background: var(--accent); + color: white; + border: none; + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; +} + +.save-btn:hover { + background: var(--accent-hover); +} + +/* ================================ + SVG icons + ================================ */ +svg { + display: block; + pointer-events: none; +} \ No newline at end of file