--- /dev/null
+# 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}"]
--- /dev/null
+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
--- /dev/null
+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"}
--- /dev/null
+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()
--- /dev/null
+#!/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
--- /dev/null
+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
--- /dev/null
+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 ? "✔" : ""}</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
--- /dev/null
+<!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>
--- /dev/null
+/* ================================
+ 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