Initial commit
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
data/
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
data/
|
||||||
16
.vscode/settings.json
vendored
Normal file
16
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"workbench.colorCustomizations": {
|
||||||
|
"tree.indentGuidesStroke": "#3d92ec",
|
||||||
|
"activityBar.background": "#1E1E5C",
|
||||||
|
"titleBar.activeBackground": "#2A2A81",
|
||||||
|
"titleBar.activeForeground": "#FCFCFE",
|
||||||
|
"titleBar.inactiveBackground": "#1E1E5C",
|
||||||
|
"titleBar.inactiveForeground": "#FCFCFE",
|
||||||
|
"statusBar.background": "#1E1E5C",
|
||||||
|
"statusBar.foreground": "#FCFCFE",
|
||||||
|
"statusBar.debuggingBackground": "#1E1E5C",
|
||||||
|
"statusBar.debuggingForeground": "#FCFCFE",
|
||||||
|
"statusBar.noFolderBackground": "#1E1E5C",
|
||||||
|
"statusBar.noFolderForeground": "#FCFCFE"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends nmap \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
63
README.md
Normal file
63
README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# NetTrak
|
||||||
|
|
||||||
|
NetTrak is a Dockerized network inventory web app that scans a subnet and catalogs:
|
||||||
|
- Devices discovered on the network
|
||||||
|
- Open ports per device
|
||||||
|
- Service fingerprint details from `nmap`
|
||||||
|
- HTTP headers and lightweight banners when available
|
||||||
|
|
||||||
|
Results are persisted in SQLite for change tracking (new/updated/missing devices and ports).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Dark mode UI by default
|
||||||
|
- 3-pane layout:
|
||||||
|
- Left: discovered machines
|
||||||
|
- Right-top: selected machine details
|
||||||
|
- Right-bottom: collapsible port records with headers/banners
|
||||||
|
- Background scan execution
|
||||||
|
- SQLite persistence for historical tracking
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- Backend: FastAPI + SQLite
|
||||||
|
- Scanner: `nmap` + lightweight Python probes
|
||||||
|
- Frontend: Static HTML/CSS/JS
|
||||||
|
- Deployment: Docker / Docker Compose
|
||||||
|
|
||||||
|
## Run With Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open: `http://localhost:8080`
|
||||||
|
|
||||||
|
Database file is stored at `./data/nettrak.db` via a bind mount.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
- `NETTRAK_DB_PATH` (default: `/data/nettrak.db`)
|
||||||
|
- `NETTRAK_SUBNET` (default: `192.168.2.0/24`)
|
||||||
|
|
||||||
|
In Compose, these are already set.
|
||||||
|
|
||||||
|
## LAN Scanning Notes
|
||||||
|
|
||||||
|
- LAN host discovery can be limited in bridged container networking.
|
||||||
|
- For best results on Linux hosts, enable host networking in `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
network_mode: host
|
||||||
|
```
|
||||||
|
|
||||||
|
- Some `nmap` OS detection capabilities may require elevated privileges. The app automatically falls back if OS detection fails.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
- `GET /api/health`
|
||||||
|
- `GET /api/devices`
|
||||||
|
- `GET /api/devices/{id}`
|
||||||
|
- `GET /api/scans?limit=20`
|
||||||
|
- `POST /api/scans/run?subnet=192.168.2.0/24`
|
||||||
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
app/config.py
Normal file
5
app/config.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.getenv("NETTRAK_DB_PATH", "/data/nettrak.db")
|
||||||
|
DEFAULT_SUBNET = os.getenv("NETTRAK_SUBNET", "192.168.2.0/24")
|
||||||
|
SCAN_TIMEOUT_SECONDS = int(os.getenv("NETTRAK_SCAN_TIMEOUT", "1800"))
|
||||||
74
app/db.py
Normal file
74
app/db.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config import DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
subnet TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
host_count INTEGER DEFAULT 0,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip TEXT NOT NULL UNIQUE,
|
||||||
|
hostname TEXT,
|
||||||
|
mac TEXT,
|
||||||
|
vendor TEXT,
|
||||||
|
os_name TEXT,
|
||||||
|
first_seen TEXT NOT NULL,
|
||||||
|
last_seen TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_scan_id INTEGER,
|
||||||
|
FOREIGN KEY(last_scan_id) REFERENCES scans(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
device_id INTEGER NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL,
|
||||||
|
service TEXT,
|
||||||
|
product TEXT,
|
||||||
|
version TEXT,
|
||||||
|
extra_info TEXT,
|
||||||
|
banner TEXT,
|
||||||
|
headers_json TEXT,
|
||||||
|
first_seen TEXT NOT NULL,
|
||||||
|
last_seen TEXT NOT NULL,
|
||||||
|
is_open INTEGER NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE(device_id, port, protocol),
|
||||||
|
FOREIGN KEY(device_id) REFERENCES devices(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_devices_active ON devices(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ports_device ON ports(device_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
db_file = Path(DB_PATH)
|
||||||
|
db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_conn():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
99
app/main.py
Normal file
99
app/main.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from .config import DEFAULT_SUBNET
|
||||||
|
from .db import init_db
|
||||||
|
from .scanner import discover_hosts, scan_host
|
||||||
|
from .service import (
|
||||||
|
complete_scan,
|
||||||
|
create_scan,
|
||||||
|
fetch_device,
|
||||||
|
fetch_devices,
|
||||||
|
fetch_scans,
|
||||||
|
mark_missing_devices,
|
||||||
|
scan_state,
|
||||||
|
upsert_host,
|
||||||
|
)
|
||||||
|
|
||||||
|
app = FastAPI(title="NetTrak")
|
||||||
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def startup() -> None:
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def home() -> FileResponse:
|
||||||
|
return FileResponse("app/static/index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"scan_running": scan_state.running,
|
||||||
|
"current_scan_id": scan_state.current_scan_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/devices")
|
||||||
|
def api_devices() -> list[dict]:
|
||||||
|
return fetch_devices()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/devices/{device_id}")
|
||||||
|
def api_device(device_id: int) -> dict:
|
||||||
|
device = fetch_device(device_id)
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/scans")
|
||||||
|
def api_scans(limit: int = 20) -> list[dict]:
|
||||||
|
return fetch_scans(limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/scans/run")
|
||||||
|
def run_scan(subnet: str | None = None) -> dict[str, Any]:
|
||||||
|
subnet = subnet or DEFAULT_SUBNET
|
||||||
|
scan_id = create_scan(subnet)
|
||||||
|
if not scan_state.start(scan_id):
|
||||||
|
complete_scan(scan_id, "cancelled", 0, notes="Another scan was already running")
|
||||||
|
raise HTTPException(status_code=409, detail="Scan already running")
|
||||||
|
|
||||||
|
def worker() -> None:
|
||||||
|
host_count = 0
|
||||||
|
try:
|
||||||
|
discovered = discover_hosts(subnet)
|
||||||
|
for host in discovered:
|
||||||
|
detailed = scan_host(host["ip"])
|
||||||
|
if not detailed:
|
||||||
|
continue
|
||||||
|
host_count += 1
|
||||||
|
if not detailed.hostname and host.get("hostname"):
|
||||||
|
detailed.hostname = host["hostname"]
|
||||||
|
if not detailed.mac and host.get("mac"):
|
||||||
|
detailed.mac = host["mac"]
|
||||||
|
if not detailed.vendor and host.get("vendor"):
|
||||||
|
detailed.vendor = host["vendor"]
|
||||||
|
|
||||||
|
upsert_host(scan_id, detailed)
|
||||||
|
|
||||||
|
mark_missing_devices(scan_id)
|
||||||
|
complete_scan(scan_id, "completed", host_count)
|
||||||
|
except Exception as exc:
|
||||||
|
complete_scan(scan_id, "failed", host_count, notes=str(exc))
|
||||||
|
finally:
|
||||||
|
scan_state.finish()
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
return {"status": "started", "scan_id": scan_id, "subnet": subnet}
|
||||||
210
app/scanner.py
Normal file
210
app/scanner.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
HTTP_PORTS = {80, 81, 443, 8000, 8080, 8081, 8443, 8888}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PortResult:
|
||||||
|
port: int
|
||||||
|
protocol: str
|
||||||
|
state: str
|
||||||
|
service: str | None = None
|
||||||
|
product: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
extra_info: str | None = None
|
||||||
|
banner: str | None = None
|
||||||
|
headers: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HostResult:
|
||||||
|
ip: str
|
||||||
|
hostname: str | None = None
|
||||||
|
mac: str | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
os_name: str | None = None
|
||||||
|
ports: list[PortResult] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def run_nmap(args: list[str]) -> ET.Element:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["nmap", *args, "-oX", "-"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(f"nmap failed: {proc.stderr.strip() or proc.stdout.strip()}")
|
||||||
|
return ET.fromstring(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_hosts(subnet: str) -> list[dict[str, Any]]:
|
||||||
|
root = run_nmap(["-sn", subnet])
|
||||||
|
hosts: list[dict[str, Any]] = []
|
||||||
|
for host in root.findall("host"):
|
||||||
|
status = host.find("status")
|
||||||
|
if status is None or status.attrib.get("state") != "up":
|
||||||
|
continue
|
||||||
|
ip = None
|
||||||
|
mac = None
|
||||||
|
vendor = None
|
||||||
|
for addr in host.findall("address"):
|
||||||
|
addr_type = addr.attrib.get("addrtype")
|
||||||
|
if addr_type == "ipv4":
|
||||||
|
ip = addr.attrib.get("addr")
|
||||||
|
elif addr_type == "mac":
|
||||||
|
mac = addr.attrib.get("addr")
|
||||||
|
vendor = addr.attrib.get("vendor")
|
||||||
|
hostname = None
|
||||||
|
names = host.find("hostnames")
|
||||||
|
if names is not None:
|
||||||
|
first = names.find("hostname")
|
||||||
|
if first is not None:
|
||||||
|
hostname = first.attrib.get("name")
|
||||||
|
if ip:
|
||||||
|
hosts.append({"ip": ip, "hostname": hostname, "mac": mac, "vendor": vendor})
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
def parse_detailed_host(xml_root: ET.Element) -> HostResult | None:
|
||||||
|
host = xml_root.find("host")
|
||||||
|
if host is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
status = host.find("status")
|
||||||
|
if status is None or status.attrib.get("state") != "up":
|
||||||
|
return None
|
||||||
|
|
||||||
|
ip = None
|
||||||
|
mac = None
|
||||||
|
vendor = None
|
||||||
|
|
||||||
|
for addr in host.findall("address"):
|
||||||
|
addr_type = addr.attrib.get("addrtype")
|
||||||
|
if addr_type == "ipv4":
|
||||||
|
ip = addr.attrib.get("addr")
|
||||||
|
elif addr_type == "mac":
|
||||||
|
mac = addr.attrib.get("addr")
|
||||||
|
vendor = addr.attrib.get("vendor")
|
||||||
|
|
||||||
|
if not ip:
|
||||||
|
return None
|
||||||
|
|
||||||
|
hostname = None
|
||||||
|
names = host.find("hostnames")
|
||||||
|
if names is not None:
|
||||||
|
first = names.find("hostname")
|
||||||
|
if first is not None:
|
||||||
|
hostname = first.attrib.get("name")
|
||||||
|
|
||||||
|
os_name = None
|
||||||
|
os_match = host.find("os/osmatch")
|
||||||
|
if os_match is not None:
|
||||||
|
os_name = os_match.attrib.get("name")
|
||||||
|
|
||||||
|
result = HostResult(ip=ip, hostname=hostname, mac=mac, vendor=vendor, os_name=os_name)
|
||||||
|
|
||||||
|
for port in host.findall("ports/port"):
|
||||||
|
protocol = port.attrib.get("protocol", "tcp")
|
||||||
|
portid = int(port.attrib.get("portid", "0"))
|
||||||
|
state_node = port.find("state")
|
||||||
|
state = state_node.attrib.get("state", "unknown") if state_node is not None else "unknown"
|
||||||
|
if state != "open":
|
||||||
|
continue
|
||||||
|
|
||||||
|
service_node = port.find("service")
|
||||||
|
service = service_node.attrib.get("name") if service_node is not None else None
|
||||||
|
product = service_node.attrib.get("product") if service_node is not None else None
|
||||||
|
version = service_node.attrib.get("version") if service_node is not None else None
|
||||||
|
extrainfo = service_node.attrib.get("extrainfo") if service_node is not None else None
|
||||||
|
|
||||||
|
p = PortResult(
|
||||||
|
port=portid,
|
||||||
|
protocol=protocol,
|
||||||
|
state=state,
|
||||||
|
service=service,
|
||||||
|
product=product,
|
||||||
|
version=version,
|
||||||
|
extra_info=extrainfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
if protocol == "tcp":
|
||||||
|
banner, headers = probe_port(ip, portid)
|
||||||
|
p.banner = banner
|
||||||
|
p.headers = headers
|
||||||
|
|
||||||
|
result.ports.append(p)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def probe_port(ip: str, port: int, timeout: float = 1.5) -> tuple[str | None, dict[str, str]]:
|
||||||
|
if port not in HTTP_PORTS:
|
||||||
|
return grab_banner(ip, port, timeout), {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
if port in {443, 8443}:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with socket.create_connection((ip, port), timeout=timeout) as sock:
|
||||||
|
with context.wrap_socket(sock, server_hostname=ip) as tls_sock:
|
||||||
|
return http_probe(tls_sock, ip)
|
||||||
|
|
||||||
|
with socket.create_connection((ip, port), timeout=timeout) as sock:
|
||||||
|
return http_probe(sock, ip)
|
||||||
|
except Exception:
|
||||||
|
return grab_banner(ip, port, timeout), {}
|
||||||
|
|
||||||
|
|
||||||
|
def http_probe(sock: socket.socket, host: str) -> tuple[str | None, dict[str, str]]:
|
||||||
|
request = (
|
||||||
|
f"HEAD / HTTP/1.1\r\n"
|
||||||
|
f"Host: {host}\r\n"
|
||||||
|
"User-Agent: NetTrak/1.0\r\n"
|
||||||
|
"Connection: close\r\n\r\n"
|
||||||
|
)
|
||||||
|
sock.sendall(request.encode("ascii", errors="ignore"))
|
||||||
|
data = sock.recv(4096)
|
||||||
|
text = data.decode("utf-8", errors="ignore")
|
||||||
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
status = lines[0] if lines else None
|
||||||
|
for line in lines[1:]:
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
headers[key.strip()] = value.strip()
|
||||||
|
|
||||||
|
return status, headers
|
||||||
|
|
||||||
|
|
||||||
|
def grab_banner(ip: str, port: int, timeout: float) -> str | None:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((ip, port), timeout=timeout) as sock:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
data = sock.recv(1024)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return data.decode("utf-8", errors="ignore").strip()[:300]
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def scan_host(ip: str) -> HostResult | None:
|
||||||
|
# `-O` may fail without extra privileges, so we gracefully retry without it.
|
||||||
|
base_args = ["-Pn", "-sV", "--top-ports", "200", ip]
|
||||||
|
try:
|
||||||
|
root = run_nmap([*base_args, "-O", "--osscan-guess"])
|
||||||
|
return parse_detailed_host(root)
|
||||||
|
except Exception:
|
||||||
|
root = run_nmap(base_args)
|
||||||
|
return parse_detailed_host(root)
|
||||||
|
|
||||||
241
app/service.py
Normal file
241
app/service.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from .db import get_conn
|
||||||
|
from .scanner import HostResult
|
||||||
|
|
||||||
|
|
||||||
|
class ScanState:
|
||||||
|
def __init__(self):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.running = False
|
||||||
|
self.current_scan_id: int | None = None
|
||||||
|
|
||||||
|
def start(self, scan_id: int) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if self.running:
|
||||||
|
return False
|
||||||
|
self.running = True
|
||||||
|
self.current_scan_id = scan_id
|
||||||
|
return True
|
||||||
|
|
||||||
|
def finish(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.running = False
|
||||||
|
self.current_scan_id = None
|
||||||
|
|
||||||
|
|
||||||
|
scan_state = ScanState()
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def create_scan(subnet: str) -> int:
|
||||||
|
started = now_iso()
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO scans(subnet, started_at, status) VALUES (?, ?, ?)",
|
||||||
|
(subnet, started, "running"),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
def complete_scan(scan_id: int, status: str, host_count: int, notes: str | None = None) -> None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE scans
|
||||||
|
SET completed_at = ?, status = ?, host_count = ?, notes = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(now_iso(), status, host_count, notes, scan_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_host(scan_id: int, host: HostResult) -> int:
|
||||||
|
timestamp = now_iso()
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT id FROM devices WHERE ip = ?", (host.ip,)).fetchone()
|
||||||
|
if row:
|
||||||
|
device_id = int(row["id"])
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE devices
|
||||||
|
SET hostname = ?, mac = ?, vendor = ?, os_name = ?,
|
||||||
|
last_seen = ?, is_active = 1, last_scan_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
host.hostname,
|
||||||
|
host.mac,
|
||||||
|
host.vendor,
|
||||||
|
host.os_name,
|
||||||
|
timestamp,
|
||||||
|
scan_id,
|
||||||
|
device_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO devices(ip, hostname, mac, vendor, os_name, first_seen, last_seen, is_active, last_scan_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
host.ip,
|
||||||
|
host.hostname,
|
||||||
|
host.mac,
|
||||||
|
host.vendor,
|
||||||
|
host.os_name,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
scan_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
device_id = int(cur.lastrowid)
|
||||||
|
|
||||||
|
seen_ports = {(p.port, p.protocol) for p in host.ports}
|
||||||
|
|
||||||
|
existing_ports = conn.execute(
|
||||||
|
"SELECT id, port, protocol FROM ports WHERE device_id = ?",
|
||||||
|
(device_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for p in host.ports:
|
||||||
|
headers_json = json.dumps(p.headers)
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM ports WHERE device_id = ? AND port = ? AND protocol = ?",
|
||||||
|
(device_id, p.port, p.protocol),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE ports
|
||||||
|
SET state = ?, service = ?, product = ?, version = ?, extra_info = ?, banner = ?,
|
||||||
|
headers_json = ?, last_seen = ?, is_open = 1
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
p.state,
|
||||||
|
p.service,
|
||||||
|
p.product,
|
||||||
|
p.version,
|
||||||
|
p.extra_info,
|
||||||
|
p.banner,
|
||||||
|
headers_json,
|
||||||
|
timestamp,
|
||||||
|
int(existing["id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO ports(device_id, port, protocol, state, service, product, version, extra_info, banner,
|
||||||
|
headers_json, first_seen, last_seen, is_open)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
device_id,
|
||||||
|
p.port,
|
||||||
|
p.protocol,
|
||||||
|
p.state,
|
||||||
|
p.service,
|
||||||
|
p.product,
|
||||||
|
p.version,
|
||||||
|
p.extra_info,
|
||||||
|
p.banner,
|
||||||
|
headers_json,
|
||||||
|
timestamp,
|
||||||
|
timestamp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for rowp in existing_ports:
|
||||||
|
pair = (int(rowp["port"]), rowp["protocol"])
|
||||||
|
if pair not in seen_ports:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE ports SET is_open = 0, state = 'closed', last_seen = ? WHERE id = ?",
|
||||||
|
(timestamp, int(rowp["id"])),
|
||||||
|
)
|
||||||
|
|
||||||
|
return device_id
|
||||||
|
|
||||||
|
|
||||||
|
def mark_missing_devices(scan_id: int) -> None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE devices
|
||||||
|
SET is_active = 0
|
||||||
|
WHERE last_scan_id IS NOT NULL
|
||||||
|
AND last_scan_id <> ?
|
||||||
|
""",
|
||||||
|
(scan_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_devices() -> list[dict]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, ip, hostname, os_name, mac, vendor, is_active, first_seen, last_seen
|
||||||
|
FROM devices
|
||||||
|
ORDER BY is_active DESC, ip ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_device(device_id: int) -> dict | None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
device = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, ip, hostname, os_name, mac, vendor, is_active, first_seen, last_seen
|
||||||
|
FROM devices
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(device_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not device:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ports = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, port, protocol, state, service, product, version, extra_info, banner, headers_json,
|
||||||
|
first_seen, last_seen, is_open
|
||||||
|
FROM ports
|
||||||
|
WHERE device_id = ?
|
||||||
|
ORDER BY is_open DESC, port ASC
|
||||||
|
""",
|
||||||
|
(device_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result = dict(device)
|
||||||
|
parsed_ports = []
|
||||||
|
for row in ports:
|
||||||
|
p = dict(row)
|
||||||
|
p["headers"] = json.loads(p["headers_json"] or "{}")
|
||||||
|
p.pop("headers_json", None)
|
||||||
|
parsed_ports.append(p)
|
||||||
|
|
||||||
|
result["ports"] = parsed_ports
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_scans(limit: int = 20) -> list[dict]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, subnet, started_at, completed_at, status, host_count, notes
|
||||||
|
FROM scans
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
177
app/static/app.js
Normal file
177
app/static/app.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const deviceListEl = document.getElementById('deviceList');
|
||||||
|
const machineInfoEl = document.getElementById('machineInfo');
|
||||||
|
const portsListEl = document.getElementById('portsList');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const scanBtn = document.getElementById('scanBtn');
|
||||||
|
const subnetInput = document.getElementById('subnetInput');
|
||||||
|
|
||||||
|
let devices = [];
|
||||||
|
let selectedDeviceId = null;
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
statusText.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const res = await fetch(path, options);
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = `${res.status}`;
|
||||||
|
try {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.detail) detail = data.detail;
|
||||||
|
} catch (_) {}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
if (!iso) return '-';
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceTitle(d) {
|
||||||
|
return d.hostname ? `${d.hostname} (${d.ip})` : d.ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDeviceList() {
|
||||||
|
deviceListEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (!devices.length) {
|
||||||
|
deviceListEl.innerHTML = '<li class="device-item">No devices discovered yet.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.forEach((d) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = `device-item ${selectedDeviceId === d.id ? 'active' : ''}`;
|
||||||
|
li.innerHTML = `
|
||||||
|
<div>${deviceTitle(d)}</div>
|
||||||
|
<div class="meta">${d.os_name || 'OS unknown'} | ${d.is_active ? 'Active' : 'Missing'}</div>
|
||||||
|
`;
|
||||||
|
li.addEventListener('click', () => {
|
||||||
|
selectedDeviceId = d.id;
|
||||||
|
renderDeviceList();
|
||||||
|
loadDevice(d.id);
|
||||||
|
});
|
||||||
|
deviceListEl.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMachineInfo(d) {
|
||||||
|
machineInfoEl.classList.remove('empty');
|
||||||
|
machineInfoEl.innerHTML = `
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-card"><div class="label">Hostname</div><div class="value">${d.hostname || '-'}</div></div>
|
||||||
|
<div class="info-card"><div class="label">IP Address</div><div class="value">${d.ip}</div></div>
|
||||||
|
<div class="info-card"><div class="label">MAC Address</div><div class="value">${d.mac || '-'}</div></div>
|
||||||
|
<div class="info-card"><div class="label">Vendor</div><div class="value">${d.vendor || '-'}</div></div>
|
||||||
|
<div class="info-card"><div class="label">Operating System</div><div class="value">${d.os_name || '-'}</div></div>
|
||||||
|
<div class="info-card"><div class="label">Status</div><div class="value">${d.is_active ? 'Active' : 'Not Seen in Last Scan'}</div></div>
|
||||||
|
<div class="info-card"><div class="label">First Seen</div><div class="value">${formatDate(d.first_seen)}</div></div>
|
||||||
|
<div class="info-card"><div class="label">Last Seen</div><div class="value">${formatDate(d.last_seen)}</div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPorts(ports) {
|
||||||
|
portsListEl.innerHTML = '';
|
||||||
|
portsListEl.classList.remove('empty');
|
||||||
|
|
||||||
|
if (!ports.length) {
|
||||||
|
portsListEl.classList.add('empty');
|
||||||
|
portsListEl.textContent = 'No ports recorded for this machine.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ports.forEach((p) => {
|
||||||
|
const details = document.createElement('details');
|
||||||
|
details.className = 'port';
|
||||||
|
const svc = [p.service, p.product, p.version].filter(Boolean).join(' ');
|
||||||
|
const headers = Object.entries(p.headers || {})
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join('\n') || 'No headers captured';
|
||||||
|
|
||||||
|
details.innerHTML = `
|
||||||
|
<summary>${p.port}/${p.protocol} - ${p.state}${svc ? ` - ${svc}` : ''}</summary>
|
||||||
|
<div class="port-body">
|
||||||
|
Service: ${svc || 'Unknown'}
|
||||||
|
Extra: ${p.extra_info || '-'}
|
||||||
|
Banner: ${p.banner || '-'}
|
||||||
|
First Seen: ${formatDate(p.first_seen)}
|
||||||
|
Last Seen: ${formatDate(p.last_seen)}
|
||||||
|
Headers:\n${headers}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
portsListEl.appendChild(details);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevices() {
|
||||||
|
devices = await api('/api/devices');
|
||||||
|
renderDeviceList();
|
||||||
|
|
||||||
|
if (!selectedDeviceId && devices.length) {
|
||||||
|
selectedDeviceId = devices[0].id;
|
||||||
|
renderDeviceList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDeviceId) {
|
||||||
|
await loadDevice(selectedDeviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevice(deviceId) {
|
||||||
|
const d = await api(`/api/devices/${deviceId}`);
|
||||||
|
renderMachineInfo(d);
|
||||||
|
renderPorts(d.ports || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScan() {
|
||||||
|
const subnet = subnetInput.value.trim();
|
||||||
|
if (!subnet) return;
|
||||||
|
|
||||||
|
scanBtn.disabled = true;
|
||||||
|
setStatus(`Starting scan on ${subnet}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api(`/api/scans/run?subnet=${encodeURIComponent(subnet)}`, { method: 'POST' });
|
||||||
|
setStatus(`Scan #${result.scan_id} running on ${result.subnet}. Refreshing automatically...`);
|
||||||
|
await pollUntilComplete(result.scan_id);
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Scan failed to start: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
scanBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUntilComplete(scanId) {
|
||||||
|
for (let i = 0; i < 240; i += 1) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
const scans = await api('/api/scans?limit=1');
|
||||||
|
const latest = scans[0];
|
||||||
|
if (!latest || latest.id !== scanId) continue;
|
||||||
|
|
||||||
|
if (latest.status === 'running') {
|
||||||
|
setStatus(`Scan #${scanId} is running...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(`Scan #${scanId} ${latest.status} (${latest.host_count} hosts).`);
|
||||||
|
await loadDevices();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(`Scan #${scanId} is taking longer than expected.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
scanBtn.addEventListener('click', runScan);
|
||||||
|
|
||||||
|
(async function init() {
|
||||||
|
setStatus('Loading inventory...');
|
||||||
|
try {
|
||||||
|
await loadDevices();
|
||||||
|
setStatus('Ready.');
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(`Failed loading data: ${err.message}`);
|
||||||
|
}
|
||||||
|
})();
|
||||||
48
app/static/index.html
Normal file
48
app/static/index.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>NetTrak</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="title-wrap">
|
||||||
|
<h1>NetTrak</h1>
|
||||||
|
<p>Network inventory and port intelligence</p>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<input id="subnetInput" type="text" value="192.168.2.0/24" aria-label="Subnet" />
|
||||||
|
<button id="scanBtn">Run Scan</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<aside class="pane left-pane">
|
||||||
|
<div class="pane-head">
|
||||||
|
<h2>Discovered Machines</h2>
|
||||||
|
</div>
|
||||||
|
<ul id="deviceList" class="device-list"></ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="right-stack">
|
||||||
|
<section class="pane detail-pane">
|
||||||
|
<div class="pane-head"><h2>Machine Info</h2></div>
|
||||||
|
<div id="machineInfo" class="machine-info empty">Select a machine from the left pane.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pane ports-pane">
|
||||||
|
<div class="pane-head"><h2>Open Ports</h2></div>
|
||||||
|
<div id="portsList" class="ports-list empty">No machine selected.</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<span id="statusText">Ready.</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
214
app/static/styles.css
Normal file
214
app/static/styles.css
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg: #0a1118;
|
||||||
|
--panel: #111b24;
|
||||||
|
--panel-2: #182531;
|
||||||
|
--text: #d9e5ef;
|
||||||
|
--muted: #8fa4b6;
|
||||||
|
--accent: #5dc4ff;
|
||||||
|
--accent-2: #2f9bd5;
|
||||||
|
--ok: #6fdd8b;
|
||||||
|
--warn: #f2b84d;
|
||||||
|
--danger: #f67a7a;
|
||||||
|
--border: #253648;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 90% 0%, #193149 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 10% 100%, #0f2a3a 0%, transparent 38%),
|
||||||
|
var(--bg);
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(7, 13, 19, 0.85);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-wrap h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-wrap p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
min-width: 210px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(180deg, var(--accent), var(--accent-2));
|
||||||
|
border: none;
|
||||||
|
color: #03101a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
height: calc(100vh - 128px);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: 300px 1fr;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(160deg, var(--panel), var(--panel-2));
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-head {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-pane { overflow: hidden; }
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 6px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-item {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-item:hover { border-color: #33516b; }
|
||||||
|
.device-item.active { border-color: var(--accent); background: rgba(93,196,255,0.08); }
|
||||||
|
|
||||||
|
.device-item .meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-stack {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.machine-info, .ports-list {
|
||||||
|
padding: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
margin-top: 5px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.port {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-body {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty { color: var(--muted); }
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 250px 1fr;
|
||||||
|
height: auto;
|
||||||
|
min-height: calc(100vh - 128px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-stack {
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls { flex-direction: column; align-items: stretch; }
|
||||||
|
input { min-width: 0; }
|
||||||
|
}
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
nettrak:
|
||||||
|
build: .
|
||||||
|
container_name: nettrak
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- NETTRAK_DB_PATH=/data/nettrak.db
|
||||||
|
- NETTRAK_SUBNET=192.168.2.0/24
|
||||||
|
# For best host discovery on Linux, uncomment host networking:
|
||||||
|
# network_mode: host
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi==0.116.1
|
||||||
|
uvicorn[standard]==0.35.0
|
||||||
Reference in New Issue
Block a user