]> git.giorgioravera.it Git - network-manager.git/commitdiff
First commit
authorGiorgio Ravera <giorgio.ravera@gmail.com>
Fri, 2 Jan 2026 19:10:08 +0000 (20:10 +0100)
committerGiorgio Ravera <giorgio.ravera@gmail.com>
Fri, 2 Jan 2026 19:10:08 +0000 (20:10 +0100)
Dockerfile [new file with mode: 0644]
backend/__init__.py [new file with mode: 0644]
backend/db.py [new file with mode: 0644]
backend/main.py [new file with mode: 0644]
backend/models.py [new file with mode: 0644]
create_db.sh [new file with mode: 0755]
docker-compose.yaml [new file with mode: 0644]
frontend/app.js [new file with mode: 0644]
frontend/index.html [new file with mode: 0644]
frontend/style.css [new file with mode: 0644]

diff --git a/Dockerfile b/Dockerfile
new file mode 100644 (file)
index 0000000..229f7e6
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/backend/db.py b/backend/db.py
new file mode 100644 (file)
index 0000000..50a477a
--- /dev/null
@@ -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 (file)
index 0000000..ac2f18f
--- /dev/null
@@ -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 (file)
index 0000000..3b8107c
--- /dev/null
@@ -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 (executable)
index 0000000..0fa1f66
--- /dev/null
@@ -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" <<EOF
+PRAGMA foreign_keys = ON;
+
+-- ============================================
+--  GLOBAL SETTINGS
+-- ============================================
+CREATE TABLE settings (
+    key TEXT PRIMARY KEY,
+    value TEXT
+);
+
+INSERT INTO settings (key, value) VALUES ('domain', '${DOMAIN}');
+INSERT INTO settings (key, value) VALUES ('external_ipv4', '${PUBLIC_IP}');
+
+-- ============================================
+--  HOSTS
+-- ============================================
+CREATE TABLE 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
+);
+
+CREATE INDEX idx_hosts_name ON hosts(name);
+
+-- ============================================
+--  ALIASES
+-- ============================================
+CREATE TABLE aliases (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    host_id INTEGER NOT NULL,
+    alias TEXT NOT NULL,
+    note TEXT,
+    ssl_enabled INTEGER NOT NULL DEFAULT 0,
+    FOREIGN KEY (host_id) REFERENCES hosts(id)
+);
+
+CREATE INDEX idx_aliases_host ON aliases(host_id);
+
+-- ============================================
+--  TXT RECORDS
+-- ============================================
+CREATE TABLE txt_records (
+    id INTEGER PRIMARY KEY AUTOINCREMENT,
+    name TEXT NOT NULL,
+    value TEXT NOT NULL,
+    note TEXT,
+    host_id INTEGER,
+    FOREIGN KEY (host_id) REFERENCES hosts(id)
+);
+
+CREATE INDEX idx_txt_host ON txt_records(host_id);
+EOF
+
+echo "[✓] Database initialized successfully."
\ No newline at end of file
diff --git a/docker-compose.yaml b/docker-compose.yaml
new file mode 100644 (file)
index 0000000..92e37c8
--- /dev/null
@@ -0,0 +1,18 @@
+services:
+  network:
+    build: .
+    container_name: network
+    restart: always
+    ports:
+      - "8080:8000"
+    environment:
+      - TZ=${DOCKER_TZ}
+      - DB_PATH=/data/database.db
+    volumes:
+      - ${DOCKER_CFG_DIR}/network:/data
+    networks:
+      - proxy
+
+networks:
+  proxy:
+    external: true
diff --git a/frontend/app.js b/frontend/app.js
new file mode 100644 (file)
index 0000000..89bfa03
--- /dev/null
@@ -0,0 +1,281 @@
+let editingHostId = null;
+let sortDirection = {};
+
+// -----------------------------
+// Validate the IP address format
+// -----------------------------
+function isValidIP(ip) {
+    if (!ip || !ip.trim()) return true; // empty is allowed
+
+    const ipv4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
+    const ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::1)$/;
+
+    return ipv4.test(ip) || ipv6.test(ip);
+}
+
+// -----------------------------
+// LOAD ALL HOSTS INTO THE TABLE
+// -----------------------------
+async function loadHosts() {
+    const res = await fetch("/api/hosts");
+    const hosts = await res.json();
+
+    const tbody = document.querySelector("#hosts-table tbody");
+    tbody.innerHTML = "";
+
+    hosts.forEach(h => {
+        const tr = document.createElement("tr");
+        tr.innerHTML = `
+            <td>${h.name}</td>
+            <td>${h.ipv4 || ""}</td>
+            <td>${h.ipv6 || ""}</td>
+            <td>${h.mac || ""}</td>
+            <td>${h.note || ""}</td>
+            <td>${h.ssl_enabled ? "&#10004;" : ""}</td>
+            <td class="actions">
+                <span class="edit-btn" onclick="editHost(${h.id})">
+                    <svg width="18" height="18" viewBox="0 0 24 24" fill="#007BFF">
+                        <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 
+                        7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 
+                        0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
+                    </svg>
+                </span>
+
+                <span class="delete-btn" onclick="deleteHost(${h.id})">
+                    <svg width="18" height="18" viewBox="0 0 24 24" fill="#0099FF">
+                        <path d="M3 6h18v2H3V6zm2 3h14l-1.5 
+                        12.5h-11L5 9zm5-6h4l1 1h5v2H4V4h5l1-1z"/>
+                    </svg>
+                </span>
+            </td>
+        `;
+        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 (file)
index 0000000..dcc2e97
--- /dev/null
@@ -0,0 +1,93 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <meta charset="UTF-8">
+    <title>Network Manager</title>
+    <link rel="stylesheet" href="style.css">
+</head>
+<body>
+
+<header class="topbar">
+    <div class="logo">
+        <svg width="30" height="30" viewBox="0 0 24 24" fill="#4da3ff">
+            <circle cx="12" cy="4" r="2"/>
+            <circle cx="4" cy="12" r="2"/>
+            <circle cx="20" cy="12" r="2"/>
+            <circle cx="12" cy="20" r="2"/>
+            <line x1="12" y1="6" x2="12" y2="18" stroke="#4da3ff" stroke-width="2"/>
+            <line x1="6" y1="12" x2="18" y2="12" stroke="#4da3ff" stroke-width="2"/>
+        </svg>
+        <span>Network Manager</span>
+    </div>
+</header>
+
+<div id="toast" class="toast"></div>
+
+<section class="page-frame">
+    <div class="frame-row">
+        <h2><span class="section-title">🖧 Host List</span></h2>
+       
+        <input 
+            type="text" 
+            id="searchInput" 
+            placeholder="Ricerca..." 
+            oninput="filterHosts()" 
+            class="search-bar"
+        />
+
+        <button class="add-host-btn" onclick="openAddHostModal()">
+            + Add Host
+        </button>
+
+    </div>
+</section>
+
+<table id="hosts-table">
+    <thead>
+        <tr>
+            <th onclick="sortTable(0)">Name <span class="sort-arrow"></span></th>
+            <th onclick="sortTable(1)">IPv4 <span class="sort-arrow"></span></th>
+            <th onclick="sortTable(2)">IPv6 <span class="sort-arrow"></span></th>
+            <th onclick="sortTable(3)">MAC <span class="sort-arrow"></span></th>
+            <th onclick="sortTable(4)">Note <span class="sort-arrow"></span></th>
+            <th onclick="sortTable(5)">SSL <span class="sort-arrow"></span></th>
+            <th>Actions</th>
+        </tr>
+    </thead>
+    <tbody></tbody>
+</table>
+
+<script src="app.js"></script>
+
+<!-- Popup Add Host -->
+<div id="addHostModal" class="modal">
+    <div class="modal-content">
+        <h3>Aggiungi Host</h3>
+
+        <label>Nome</label>
+        <input type="text" id="hostName">
+
+        <label>IPv4</label>
+        <input type="text" id="hostIPv4">
+
+        <label>IPv6</label>
+        <input type="text" id="hostIPv6">
+
+        <label>MAC Address</label>
+        <input type="text" id="hostMAC">
+
+        <label>Note</label>
+        <input type="text" id="hostNote">
+
+        <label>SSL?</label>
+        <input type="checkbox" id="hostSSL">
+
+        <div class="modal-buttons">
+            <button class="cancel-btn" onclick="closeAddHostModal()">Annulla</button>
+            <button class="save-btn" onclick="saveHost()">Salva</button>
+        </div>
+    </div>
+</div>
+
+</body>
+</html>
diff --git a/frontend/style.css b/frontend/style.css
new file mode 100644 (file)
index 0000000..e4c72d4
--- /dev/null
@@ -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