Compare commits

..

55 Commits

Author SHA1 Message Date
admin 0d9468eedf fix(auth): inventoryApi - use apiFetch with /backend prefix and Authorization header 2026-08-06 16:39:57 +00:00
admin 1e0956dcc8 fix(proxy): add /backend proxy with rewrite to replace legacy per-route entries 2026-08-06 16:37:31 +00:00
admin 6927c3ea32 fix(types): InventoryView - explicit NetObject type on map callback 2026-08-06 16:35:46 +00:00
admin 472cc601a6 fix(api): add fetchObjects export for InventoryView and TopologyView compatibility 2026-08-06 16:35:31 +00:00
admin 6594d27670 fix(auth): App.tsx - use clearSession from session.ts, onLogin takes no args, check token via getToken() 2026-08-06 16:34:59 +00:00
admin fd669b81df fix(deps): restore valid package.json, add @types/react and @types/react-dom 2026-08-06 16:33:26 +00:00
admin d651fc3ff2 feat(auth): LoginPage - fix credentials, fetch /auth/me after login 2026-08-06 16:30:37 +00:00
admin 133f059f13 feat(auth): centralized api client with Authorization header 2026-08-06 16:30:20 +00:00
admin 60d8835e99 feat(auth): implement session store - token + user management 2026-08-06 16:30:02 +00:00
admin 75dd560baf feat(auth): call ensure_default_admin on startup 2026-08-06 16:23:31 +00:00
admin d61a9ee17a feat(auth): wire /auth/token to DB, add /me full response, POST /users (admin), PATCH /me/password 2026-08-06 16:23:17 +00:00
admin 437dbfe148 feat(auth): wire auth.py to UserModel DB, drop fake_users_db, add require_role 2026-08-06 16:22:58 +00:00
admin 4c983d4ab6 chore: remove CI workflow (no runner available, re-add when act_runner is configured) 2026-08-06 16:19:51 +00:00
admin 1c3cb28af1 fix(db): add missing updated_at to JobModel and DiscoveryRunModel
CI / basic-check (push) Has been cancelled
2026-08-06 16:15:49 +00:00
admin 87757ac1ea feat(db): wire discoveries router to SQLAlchemy, add POST + cancel
CI / basic-check (push) Has been cancelled
2026-08-06 16:12:52 +00:00
admin 54976814fc feat(db): wire jobs router to SQLAlchemy, add cancel endpoint
CI / basic-check (push) Has been cancelled
2026-08-06 16:12:34 +00:00
admin 76886b93d3 feat(crud): full CRUD for Relation — wire to SQLAlchemy, drop store.py dep
CI / basic-check (push) Has been cancelled
2026-08-06 16:10:56 +00:00
admin f47443ad98 feat(crud): full CRUD endpoints for NetObject (POST/PUT/PATCH/DELETE)
CI / basic-check (push) Has been cancelled
2026-08-06 16:09:13 +00:00
admin 45fb390cb7 feat(crud): add NetObjectCreate, NetObjectUpdate, RelationCreate, RelationUpdate schemas
CI / basic-check (push) Has been cancelled
2026-08-06 16:08:26 +00:00
admin fd93758567 feat(db): add seed script for mock data
CI / basic-check (push) Has been cancelled
2026-08-06 16:04:05 +00:00
admin a732ac5655 feat(db): health check now pings SQLite DB for real status
CI / basic-check (push) Has been cancelled
2026-08-06 16:03:48 +00:00
admin 8c0d3d6a26 feat(db): rewrite inventory service part 1 - helpers and tree
CI / basic-check (push) Has been cancelled
2026-08-06 16:03:26 +00:00
admin 70dd820e88 feat(db): wire objects router to SQLAlchemy session
CI / basic-check (push) Has been cancelled
2026-08-06 16:02:18 +00:00
admin 5c09359ee8 feat(db): wire inventory router to SQLAlchemy session via Depends(get_db)
CI / basic-check (push) Has been cancelled
2026-08-06 16:02:05 +00:00
admin 143baddf0a feat(db): register all routers and init SQLite DB on startup
CI / basic-check (push) Has been cancelled
2026-08-06 15:52:50 +00:00
admin 9e5cd580db feat(db): add SQLAlchemy ORM models for all entities
CI / basic-check (push) Has been cancelled
2026-08-06 15:52:36 +00:00
admin 0822cb1a60 feat(db): add SQLite database engine and session management
CI / basic-check (push) Has been cancelled
2026-08-06 15:52:16 +00:00
admin 9ca17c7820 feat(db): add sqlalchemy to requirements
CI / basic-check (push) Has been cancelled
2026-08-06 15:52:06 +00:00
root 6d635109d3 fix(auth): restore auth.py as valid Python (closes #2)
CI / basic-check (push) Has been cancelled
2026-08-06 15:01:56 +00:00
admin f8e191a5d5 fix(auth): use stored hashed_password instead of rehashing on every login
CI / basic-check (push) Has been cancelled
Closes #2
2026-08-06 14:55:29 +00:00
admin 092bda19eb feat: add @xyflow/react dependency for topology canvas
CI / basic-check (push) Has been cancelled
2026-08-05 08:27:39 +00:00
admin 15b18d3421 Update frontend/vite.config.ts
CI / basic-check (push) Has been cancelled
2026-08-04 20:50:52 +00:00
Perplexity Bot fa6561de58 Fix demo login credentials and form encoding
CI / basic-check (push) Has been cancelled
2026-08-04 20:27:26 +00:00
Perplexity Bot a6d0dd6c68 Fix demo login credentials and form encoding
CI / basic-check (push) Has been cancelled
2026-08-04 20:25:33 +00:00
Perplexity Bot 8aae681706 Add basic FastAPI auth and React login gate
CI / basic-check (push) Has been cancelled
2026-08-04 20:21:34 +00:00
Perplexity Bot 998c5246dd Remove WIP tab from frontend, keep roadmap only in repo
CI / basic-check (push) Has been cancelled
2026-08-04 20:19:19 +00:00
Perplexity Bot 12a6dffcb9 Remove WIP blocks from UI, move roadmap to Gitea and add ROADMAP.md
CI / basic-check (push) Has been cancelled
2026-08-04 20:10:57 +00:00
Perplexity Bot 95a6992f62 Add options section and security roadmap to app shell
CI / basic-check (push) Has been cancelled
2026-08-04 20:08:38 +00:00
Perplexity Bot 0dffde8f87 Restore app shell with tabs and WIP sections around inventory
CI / basic-check (push) Has been cancelled
2026-08-04 20:02:11 +00:00
Perplexity Bot d2fd4d1078 Add clickable relation navigation to inventory panel
CI / basic-check (push) Has been cancelled
2026-08-04 19:58:31 +00:00
Perplexity Bot 92b0136015 Route frontend inventory calls to backend via Vite proxy
CI / basic-check (push) Has been cancelled
2026-08-04 19:55:46 +00:00
Perplexity Bot 3567c17bb1 Revert inventory API to relative paths for Vite dev proxy
CI / basic-check (push) Has been cancelled
2026-08-04 19:54:48 +00:00
Perplexity Bot d16dcfe66e Use direct backend URLs for inventory API and guard JSON parsing
CI / basic-check (push) Has been cancelled
2026-08-04 19:53:12 +00:00
Perplexity Bot eb0e7b8f97 Fix inventory UI API paths for current backend routing
CI / basic-check (push) Has been cancelled
2026-08-04 19:51:46 +00:00
admin d29a05fff6 Update frontend/vite.config.ts
CI / basic-check (push) Has been cancelled
2026-08-04 19:42:02 +00:00
admin 49def19561 Update frontend/src/main.tsx
CI / basic-check (push) Has been cancelled
2026-08-04 19:41:30 +00:00
Perplexity Bot 8b2ed677a7 Add React inventory UI with tree and node context panel
CI / basic-check (push) Has been cancelled
2026-08-04 19:25:39 +00:00
Perplexity Bot 4466e59684 Add inventory relations model and API structure
CI / basic-check (push) Has been cancelled
2026-08-04 19:20:19 +00:00
Perplexity Bot 69124b31f1 Add Pydantic models and FastAPI router structure for agent and backend
CI / basic-check (push) Has been cancelled
2026-08-04 18:55:54 +00:00
Perplexity Bot dc1b2ab99b Fix frontend API: use relative /api with Vite proxy to agent
CI / basic-check (push) Has been cancelled
2026-08-03 22:40:24 +00:00
Perplexity Bot 186aae1b5f Add services status view with polling and control actions
CI / basic-check (push) Has been cancelled
2026-08-03 22:22:59 +00:00
Perplexity Bot be55c35c3a Add minimal control agent and systemd unit templates
CI / basic-check (push) Has been cancelled
2026-08-03 22:13:46 +00:00
Perplexity Bot 871f4d0547 Add basic frontend: sidebar, views and inventory table
CI / basic-check (push) Has been cancelled
2026-08-03 22:06:00 +00:00
Perplexity Bot 00ecb1862d Add backend, worker, deploy docker-compose and CI skeleton
CI / basic-check (push) Has been cancelled
2026-08-03 21:42:28 +00:00
admin 0294301877 Delete TEST.md 2026-08-03 21:41:40 +00:00
60 changed files with 3165 additions and 5 deletions
+4
View File
@@ -33,3 +33,7 @@ NetMapper è una web app self-hosted per la mappatura dinamica di un homelab su
- Database: PostgreSQL - Database: PostgreSQL
- Cache/queue: Redis - Cache/queue: Redis
- Deploy: Docker Compose - Deploy: Docker Compose
### Agent
- `agent/` - control agent per gestire i servizi systemd (start/stop/status).
+72
View File
@@ -0,0 +1,72 @@
# NetMapper Roadmap
La roadmap e il backlog di sviluppo sono centralizzati su Gitea:
- Issues: https://git.lelekaos.com/admin/NetMapper2026/issues
- Board: https://git.lelekaos.com/admin/NetMapper2026/projects/1
- Milestone: https://git.lelekaos.com/admin/NetMapper2026/milestones
## Milestone principali
### MVP Inventory
- Seed scan subnet e discovery base.
- Tree fisico/logico e contesto nodo.
- Navigazione tra parent, children e relazioni.
### Security foundation
- Autenticazione e sessioni.
- Ruoli base e autorizzazione per risorse sensibili.
- Audit accessi e logout sicuro.
### Topology multilivello
- Vista fisica dispositivi.
- Vista logica host / VM / container / app.
- Layer networking e dipendenze.
### Remote access hardening
- MFA per admin e azioni critiche.
- Terminale remoto protetto (stile Termix).
- Audit comandi e approvazione esplicita.
## WIP interni
Questa sezione è riservata ai WIP interni e al backlog tecnico.
Non deve essere esposta nel frontend dell'app.
### Auth foundation
- Sessioni sicure, logout reale, protezione delle rotte.
- Bootstrap identità utente all'avvio.
- Ruoli base (admin, editor, viewer).
- Policy per risorse sensibili (inventory, settings, utenti).
### Inventory hardening
- Validazione input e output su API.
- Audit log per modifiche a nodi e relazioni.
- Permessi per creazione/modifica/cancellazione.
### Discovery pipeline
- SNMP collector — WIP.
- LLDP ingestion — WIP.
- Port mapping inference — WIP.
- Correlazione automatica con relazioni inventory.
### Topology multilivello
- Vista fisica dispositivi.
- Vista logica host / VM / container / app.
- Layer networking e dipendenze.
- Drag & drop, focus node, highlight vicini.
### Remote access hardening
- MFA per admin e azioni critiche.
- Terminale remoto protetto (stile Termix).
- Audit comandi e approvazione esplicita.
- Tracciamento sessioni e log comandi.
-1
View File
@@ -1 +0,0 @@
asdasdasd
+15
View File
@@ -0,0 +1,15 @@
from pydantic import BaseModel
class AgentConfig(BaseModel):
service_name: str = 'netmapper-agent'
version: str = '0.1.0'
monitored_services: list[str] = [
'netmapper-agent.service',
'netmapper-backend.service',
'netmapper-worker.service',
'netmapper-frontend.service',
]
settings = AgentConfig()
+20
View File
@@ -0,0 +1,20 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers.health import router as health_router
from app.routers.runtime import router as runtime_router
from app.routers.services import router as services_router
app = FastAPI(title='NetMapper Agent', version='0.1.0')
app.add_middleware(
CORSMiddleware,
allow_origins=['*'],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
app.include_router(health_router)
app.include_router(runtime_router)
app.include_router(services_router)
+62
View File
@@ -0,0 +1,62 @@
from datetime import datetime
from typing import Dict, List, Literal, Optional
from pydantic import BaseModel, Field
HealthStatus = Literal['pass', 'warn', 'fail']
ActionStatus = Literal['accepted', 'completed', 'failed']
ServiceActiveState = Literal['active', 'reloading', 'inactive', 'failed', 'activating', 'deactivating']
class CheckResult(BaseModel):
status: HealthStatus
latency_ms: Optional[float] = None
detail: Optional[str] = None
class HealthResponse(BaseModel):
status: HealthStatus
service: str
version: Optional[str] = None
time: datetime
uptime_s: Optional[float] = None
checks: Dict[str, CheckResult] = Field(default_factory=dict)
class ServiceState(BaseModel):
name: str
description: Optional[str] = None
active_state: ServiceActiveState
sub_state: str
loaded: str
status: HealthStatus
pid: Optional[int] = None
since: Optional[datetime] = None
health_url: Optional[str] = None
reachable: Optional[bool] = None
notes: Optional[str] = None
class ServiceListResponse(BaseModel):
count: int
time: datetime
items: List[ServiceState]
class RuntimeResponse(BaseModel):
status: HealthStatus
service: str
version: Optional[str] = None
time: datetime
hostname: str
uptime_s: Optional[float] = None
services: List[ServiceState]
checks: Dict[str, CheckResult] = Field(default_factory=dict)
class ServiceActionResponse(BaseModel):
status: ActionStatus
action: Literal['start', 'stop', 'restart']
service: str
job_id: Optional[str] = None
message: Optional[str] = None
time: datetime
+21
View File
@@ -0,0 +1,21 @@
from fastapi import APIRouter
from app.models import HealthResponse
from app.services.runtime_probe import build_health
router = APIRouter(tags=['health'])
@router.get('/live', response_model=HealthResponse)
def live() -> HealthResponse:
return build_health()
@router.get('/ready', response_model=HealthResponse)
def ready() -> HealthResponse:
return build_health()
@router.get('/health', response_model=HealthResponse)
def health() -> HealthResponse:
return build_health()
+13
View File
@@ -0,0 +1,13 @@
from fastapi import APIRouter
from app.models import RuntimeResponse
from app.services.runtime_probe import build_runtime
from app.services.systemd_manager import SystemdManager
router = APIRouter(tags=['runtime'])
manager = SystemdManager()
@router.get('/runtime', response_model=RuntimeResponse)
def runtime() -> RuntimeResponse:
return build_runtime(manager)
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, HTTPException
from app.config import settings
from app.models import ServiceActionResponse, ServiceListResponse, ServiceState
from app.services.systemd_manager import SystemdManager
router = APIRouter(prefix='/services', tags=['services'])
manager = SystemdManager()
@router.get('', response_model=ServiceListResponse)
def list_services() -> ServiceListResponse:
items = manager.list_services(settings.monitored_services)
return ServiceListResponse(count=len(items), time=datetime.now(timezone.utc), items=items)
@router.get('/{name}', response_model=ServiceState)
def get_service(name: str) -> ServiceState:
items = manager.list_services([name])
if not items:
raise HTTPException(status_code=404, detail='Service not found')
return items[0]
@router.post('/{name}/{action}', response_model=ServiceActionResponse)
def service_action(name: str, action: Literal['start', 'stop', 'restart']) -> ServiceActionResponse:
if action == 'start':
manager.start(name)
elif action == 'stop':
manager.stop(name)
elif action == 'restart':
manager.restart(name)
return ServiceActionResponse(
status='accepted',
action=action,
service=name,
time=datetime.now(timezone.utc),
message=f'{action} requested',
)
+59
View File
@@ -0,0 +1,59 @@
import socket
import time
from datetime import datetime, timezone
from urllib.request import urlopen
from app.config import settings
from app.models import CheckResult, HealthResponse, RuntimeResponse
from app.services.systemd_manager import SystemdManager
START_TIME = time.time()
HEALTH_TARGETS = {
'backend': 'http://127.0.0.1:8000/health',
'worker': None,
'frontend': 'http://127.0.0.1:5173/',
}
def probe_http(url: str | None) -> CheckResult:
if not url:
return CheckResult(status='warn', detail='no health endpoint configured')
try:
started = time.time()
with urlopen(url, timeout=2) as resp:
latency = round((time.time() - started) * 1000, 2)
return CheckResult(status='pass' if resp.status < 400 else 'fail', latency_ms=latency, detail=f'HTTP {resp.status}')
except Exception as exc:
return CheckResult(status='fail', detail=str(exc))
def build_health() -> HealthResponse:
return HealthResponse(
status='pass',
service=settings.service_name,
version=settings.version,
time=datetime.now(timezone.utc),
uptime_s=round(time.time() - START_TIME, 2),
checks={'dbus': CheckResult(status='pass', detail='system bus reachable')},
)
def build_runtime(systemd_manager: SystemdManager) -> RuntimeResponse:
services = systemd_manager.list_services(settings.monitored_services)
checks = {'dbus': CheckResult(status='pass', detail='system bus reachable')}
checks.update({k: probe_http(v) for k, v in HEALTH_TARGETS.items()})
overall = 'pass'
if any(c.status == 'fail' for c in checks.values()) or any(s.status == 'fail' for s in services):
overall = 'fail'
elif any(c.status == 'warn' for c in checks.values()) or any(s.status == 'warn' for s in services):
overall = 'warn'
return RuntimeResponse(
status=overall,
service=settings.service_name,
version=settings.version,
time=datetime.now(timezone.utc),
hostname=socket.gethostname(),
uptime_s=round(time.time() - START_TIME, 2),
services=services,
checks=checks,
)
+64
View File
@@ -0,0 +1,64 @@
from datetime import datetime, timezone
from typing import List
import dbus
from app.models import ServiceState
class SystemdManager:
def __init__(self) -> None:
self.bus = dbus.SystemBus()
systemd = self.bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1')
self.manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager')
def list_services(self, names: list[str]) -> List[ServiceState]:
items: list[ServiceState] = []
for name in names:
try:
unit_path = self.manager.GetUnit(name)
unit = self.bus.get_object('org.freedesktop.systemd1', unit_path)
props = dbus.Interface(unit, 'org.freedesktop.DBus.Properties')
active_state = str(props.Get('org.freedesktop.systemd1.Unit', 'ActiveState'))
sub_state = str(props.Get('org.freedesktop.systemd1.Unit', 'SubState'))
loaded = str(props.Get('org.freedesktop.systemd1.Unit', 'LoadState'))
description = str(props.Get('org.freedesktop.systemd1.Unit', 'Description'))
try:
pid = int(props.Get('org.freedesktop.systemd1.Service', 'MainPID'))
if pid == 0:
pid = None
except Exception:
pid = None
status = 'pass' if active_state == 'active' else 'warn' if active_state in {'activating', 'deactivating'} else 'fail'
items.append(ServiceState(
name=name,
description=description,
active_state=active_state,
sub_state=sub_state,
loaded=loaded,
status=status,
pid=pid,
since=datetime.now(timezone.utc),
))
except Exception as exc:
items.append(ServiceState(
name=name,
active_state='failed',
sub_state='not-found',
loaded='not-found',
status='fail',
notes=str(exc),
))
return items
def _act(self, name: str, method: str) -> None:
fn = getattr(self.manager, method)
fn(name, 'replace')
def start(self, name: str) -> None:
self._act(name, 'StartUnit')
def stop(self, name: str) -> None:
self._act(name, 'StopUnit')
def restart(self, name: str) -> None:
self._act(name, 'RestartUnit')
+4
View File
@@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
dbus-python==1.3.2
pydantic==2.9.2
+138
View File
@@ -0,0 +1,138 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import UserModel
SECRET_KEY = "change-me-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 120
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
# ---------- Pydantic schemas ----------
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
username: Optional[str] = None
class UserOut(BaseModel):
id: int
username: str
email: Optional[str] = None
role: str
disabled: bool
force_password_change: bool
created_at: datetime
class Config:
from_attributes = True
# ---------- Crypto helpers ----------
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
# ---------- DB helpers ----------
def get_user_by_username(db: Session, username: str) -> Optional[UserModel]:
return db.query(UserModel).filter(UserModel.username == username).first()
def authenticate_user(db: Session, username: str, password: str) -> Optional[UserModel]:
user = get_user_by_username(db, username)
if not user or not verify_password(password, user.hashed_password):
return None
return user
def ensure_default_admin(db: Session) -> None:
"""Seed a default admin user if the users table is empty (first-run bootstrap)."""
if db.query(UserModel).count() == 0:
db.add(UserModel(
username="admin",
hashed_password=hash_password("admin"),
role="admin",
disabled=False,
force_password_change=False,
))
db.add(UserModel(
username="viewer",
hashed_password=hash_password("viewer"),
role="viewer",
disabled=False,
force_password_change=False,
))
db.commit()
# ---------- FastAPI dependencies ----------
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> UserModel:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if not username:
raise credentials_exception
except JWTError:
raise credentials_exception
user = get_user_by_username(db, username)
if user is None or user.disabled:
raise credentials_exception
return user
def get_current_active_user(current_user: UserModel = Depends(get_current_user)) -> UserModel:
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
def require_role(*roles: str):
"""Dependency factory: require one of the given roles."""
def _check(current_user: UserModel = Depends(get_current_active_user)) -> UserModel:
if current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Required role: {' or '.join(roles)}",
)
return current_user
return _check
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
DATABASE_URL = "sqlite:///./netmapper.db"
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
+51
View File
@@ -0,0 +1,51 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.auth import ensure_default_admin
from app.database import Base, engine, SessionLocal
from app.routes.auth import router as auth_router
from app.routers.inventory import router as inventory_router
from app.routers.relations import router as relations_router
from app.routers.discoveries import router as discoveries_router
from app.routers.jobs import router as jobs_router
from app.routers.objects import router as objects_router
from app.routers.health import router as health_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create all tables
Base.metadata.create_all(bind=engine)
# Seed default users if DB is empty
db = SessionLocal()
try:
ensure_default_admin(db)
finally:
db.close()
yield
app = FastAPI(title="NetMapper API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router)
app.include_router(health_router)
app.include_router(inventory_router)
app.include_router(relations_router)
app.include_router(discoveries_router)
app.include_router(jobs_router)
app.include_router(objects_router)
@app.get("/health")
async def health():
return {"status": "ok"}
+104
View File
@@ -0,0 +1,104 @@
import json
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, Text
from app.database import Base
def _now():
return datetime.now(timezone.utc)
class NetObjectModel(Base):
__tablename__ = "net_objects"
id = Column(String, primary_key=True, index=True)
name = Column(String, nullable=False)
slug = Column(String, nullable=True)
type = Column(String, nullable=False)
parent_id = Column(String, nullable=True, index=True)
status = Column(String, nullable=False, default="active")
vendor = Column(String, nullable=True)
model = Column(String, nullable=True)
serial = Column(String, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_now)
updated_at = Column(DateTime(timezone=True), default=_now, onupdate=_now)
class RelationModel(Base):
__tablename__ = "relations"
id = Column(String, primary_key=True, index=True)
type = Column(String, nullable=False)
layer = Column(String, nullable=False)
source_id = Column(String, nullable=False, index=True)
target_id = Column(String, nullable=False, index=True)
direction = Column(String, nullable=False, default="forward")
source = Column(String, nullable=False)
confidence = Column(Float, nullable=False, default=1.0)
status = Column(String, nullable=False, default="active")
attributes_json = Column(Text, nullable=True, default="{}")
created_at = Column(DateTime(timezone=True), default=_now)
updated_at = Column(DateTime(timezone=True), default=_now, onupdate=_now)
@property
def attributes(self):
return json.loads(self.attributes_json or "{}")
@attributes.setter
def attributes(self, value):
self.attributes_json = json.dumps(value)
class JobModel(Base):
__tablename__ = "jobs"
id = Column(String, primary_key=True, index=True)
type = Column(String, nullable=False)
status = Column(String, nullable=False, default="pending")
progress = Column(Integer, nullable=False, default=0)
message = Column(Text, nullable=True)
error = Column(Text, nullable=True)
payload_json = Column(Text, nullable=True)
result_json = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_now)
updated_at = Column(DateTime(timezone=True), default=_now, onupdate=_now)
started_at = Column(DateTime(timezone=True), nullable=True)
finished_at = Column(DateTime(timezone=True), nullable=True)
class DiscoveryProfileModel(Base):
__tablename__ = "discovery_profiles"
id = Column(String, primary_key=True, index=True)
name = Column(String, nullable=False)
strategy = Column(String, nullable=False, default="fast")
use_icmp = Column(Boolean, nullable=False, default=True)
use_arp = Column(Boolean, nullable=False, default=True)
use_snmp = Column(Boolean, nullable=False, default=False)
use_lldp = Column(Boolean, nullable=False, default=False)
use_portscan = Column(Boolean, nullable=False, default=False)
notes = Column(Text, nullable=True)
class DiscoveryRunModel(Base):
__tablename__ = "discovery_runs"
id = Column(String, primary_key=True, index=True)
profile_id = Column(String, nullable=False, index=True)
subnet_ids_json = Column(Text, nullable=False, default="[]")
job_id = Column(String, nullable=True)
status = Column(String, nullable=False, default="pending")
summary_json = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=_now)
updated_at = Column(DateTime(timezone=True), default=_now, onupdate=_now)
started_at = Column(DateTime(timezone=True), nullable=True)
finished_at = Column(DateTime(timezone=True), nullable=True)
class UserModel(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String, unique=True, nullable=False, index=True)
email = Column(String, unique=True, nullable=True)
hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="viewer")
disabled = Column(Boolean, nullable=False, default=False)
force_password_change = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), default=_now)
updated_at = Column(DateTime(timezone=True), default=_now, onupdate=_now)
+95
View File
@@ -0,0 +1,95 @@
import json
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import DiscoveryRunModel, JobModel
from app.schemas import DiscoveryRun, EntityResponse, ListResponse
router = APIRouter(prefix='/discoveries', tags=['discoveries'])
class DiscoveryRunCreate(BaseModel):
profile_id: str
subnet_ids: list[str] = []
def _to_run(m: DiscoveryRunModel) -> DiscoveryRun:
return DiscoveryRun(
id=m.id, profile_id=m.profile_id,
subnet_ids=json.loads(m.subnet_ids_json or '[]'),
job_id=m.job_id, status=m.status,
summary=json.loads(m.summary_json or 'null'),
created_at=m.created_at,
started_at=m.started_at,
finished_at=m.finished_at,
)
@router.get('', response_model=ListResponse[DiscoveryRun])
def list_discoveries(
status: str | None = Query(default=None),
db: Session = Depends(get_db),
):
q = db.query(DiscoveryRunModel)
if status:
q = q.filter(DiscoveryRunModel.status == status)
rows = q.order_by(DiscoveryRunModel.created_at.desc()).all()
return ListResponse(count=len(rows), items=[_to_run(r) for r in rows])
@router.get('/{discovery_id}', response_model=EntityResponse[DiscoveryRun])
def get_discovery(discovery_id: str, db: Session = Depends(get_db)):
row = db.query(DiscoveryRunModel).filter(DiscoveryRunModel.id == discovery_id).first()
if not row:
raise HTTPException(status_code=404, detail='Discovery not found')
return EntityResponse(item=_to_run(row))
@router.post('', response_model=EntityResponse[DiscoveryRun], status_code=201)
def create_discovery(body: DiscoveryRunCreate, db: Session = Depends(get_db)):
now = datetime.now(timezone.utc)
run_id = f'run_{uuid4().hex[:8]}'
job_id = f'job_{uuid4().hex[:8]}'
job = JobModel(
id=job_id, type='discovery', status='pending',
progress=0, payload_json=json.dumps({'run_id': run_id}),
created_at=now, updated_at=now,
)
run = DiscoveryRunModel(
id=run_id, profile_id=body.profile_id,
subnet_ids_json=json.dumps(body.subnet_ids),
job_id=job_id, status='pending',
created_at=now, updated_at=now,
)
db.add(job)
db.add(run)
db.commit()
db.refresh(run)
return EntityResponse(item=_to_run(run))
@router.post('/{discovery_id}/cancel', response_model=EntityResponse[DiscoveryRun])
def cancel_discovery(discovery_id: str, db: Session = Depends(get_db)):
run = db.query(DiscoveryRunModel).filter(DiscoveryRunModel.id == discovery_id).first()
if not run:
raise HTTPException(status_code=404, detail='Discovery not found')
if run.status not in ('pending', 'running'):
raise HTTPException(
status_code=409,
detail=f'Cannot cancel discovery with status {run.status!r}',
)
run.status = 'cancelled'
run.finished_at = datetime.now(timezone.utc)
if run.job_id:
job = db.query(JobModel).filter(JobModel.id == run.job_id).first()
if job and job.status in ('pending', 'running'):
job.status = 'cancelled'
job.finished_at = run.finished_at
db.commit()
db.refresh(run)
return EntityResponse(item=_to_run(run))
+47
View File
@@ -0,0 +1,47 @@
import time
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas import HealthCheck, HealthResponse
router = APIRouter(tags=['health'])
START_TIME = time.time()
def build_health(db: Session) -> HealthResponse:
db_check: HealthCheck
try:
t0 = time.perf_counter()
db.execute(text("SELECT 1"))
latency_ms = round((time.perf_counter() - t0) * 1000, 2)
db_check = HealthCheck(status='pass', latency_ms=latency_ms, detail='sqlite ok')
except Exception as exc:
db_check = HealthCheck(status='fail', detail=str(exc))
overall = 'pass' if db_check.status == 'pass' else 'fail'
return HealthResponse(
status=overall,
service='netmapper-backend',
version='0.1.0',
time=datetime.now(timezone.utc),
uptime_s=round(time.time() - START_TIME, 2),
checks={'db': db_check},
)
@router.get('/live', response_model=HealthResponse)
def live(db: Session = Depends(get_db)) -> HealthResponse:
return build_health(db)
@router.get('/ready', response_model=HealthResponse)
def ready(db: Session = Depends(get_db)) -> HealthResponse:
return build_health(db)
@router.get('/health', response_model=HealthResponse)
def health(db: Session = Depends(get_db)) -> HealthResponse:
return build_health(db)
+56
View File
@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas import EntityResponse, InventoryTreeNode, ListResponse, NodeContext, Relation
from app.services.inventory import build_inventory_tree, build_node_context, get_badges, get_node_relations
router = APIRouter(prefix='/inventory', tags=['inventory'])
@router.get('/tree', response_model=ListResponse[InventoryTreeNode])
def inventory_tree(
root_id: str | None = None,
depth: int | None = Query(default=None, ge=0),
db: Session = Depends(get_db),
):
items = build_inventory_tree(db, root_id=root_id, depth=depth)
return ListResponse(count=len(items), items=items)
@router.get('/tree/{item_id}', response_model=EntityResponse[InventoryTreeNode])
def inventory_subtree(
item_id: str,
depth: int | None = Query(default=None, ge=0),
db: Session = Depends(get_db),
):
items = build_inventory_tree(db, root_id=item_id, depth=depth)
if not items:
raise HTTPException(status_code=404, detail='Inventory node not found')
return EntityResponse(item=items[0])
@router.get('/nodes/{item_id}/context', response_model=EntityResponse[NodeContext])
def node_context(item_id: str, db: Session = Depends(get_db)):
item = build_node_context(db, item_id)
if not item:
raise HTTPException(status_code=404, detail='Inventory node not found')
return EntityResponse(item=item)
@router.get('/nodes/{item_id}/badges', response_model=ListResponse[str])
def node_badges(item_id: str, db: Session = Depends(get_db)):
badges = get_badges(db, item_id)
return ListResponse(count=len(badges), items=badges)
@router.get('/nodes/{item_id}/relations', response_model=ListResponse[Relation])
def node_relations(
item_id: str,
layer: str | None = None,
type: str | None = Query(default=None),
direction: str = Query(default='both', pattern='^(incoming|outgoing|both)$'),
db: Session = Depends(get_db),
):
items = get_node_relations(db, item_id, layer=layer, rel_type=type, direction=direction)
return ListResponse(count=len(items), items=items)
+63
View File
@@ -0,0 +1,63 @@
import json
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import JobModel
from app.schemas import EntityResponse, Job, JobStatus, ListResponse
router = APIRouter(prefix='/jobs', tags=['jobs'])
def _to_job(m: JobModel) -> Job:
return Job(
id=m.id, type=m.type, status=m.status,
progress=m.progress, message=m.message, error=m.error,
payload=json.loads(m.payload_json or '{}'),
result=json.loads(m.result_json or 'null'),
created_at=m.created_at,
started_at=m.started_at,
finished_at=m.finished_at,
)
@router.get('', response_model=ListResponse[Job])
def list_jobs(
status: JobStatus | None = Query(default=None),
type: str | None = Query(default=None),
db: Session = Depends(get_db),
):
q = db.query(JobModel)
if status:
q = q.filter(JobModel.status == status)
if type:
q = q.filter(JobModel.type == type)
rows = q.order_by(JobModel.created_at.desc()).all()
return ListResponse(count=len(rows), items=[_to_job(r) for r in rows])
@router.get('/{job_id}', response_model=EntityResponse[Job])
def get_job(job_id: str, db: Session = Depends(get_db)):
row = db.query(JobModel).filter(JobModel.id == job_id).first()
if not row:
raise HTTPException(status_code=404, detail='Job not found')
return EntityResponse(item=_to_job(row))
@router.post('/{job_id}/cancel', response_model=EntityResponse[Job])
def cancel_job(job_id: str, db: Session = Depends(get_db)):
row = db.query(JobModel).filter(JobModel.id == job_id).first()
if not row:
raise HTTPException(status_code=404, detail='Job not found')
if row.status not in ('pending', 'running'):
raise HTTPException(
status_code=409,
detail=f'Cannot cancel job with status {row.status!r}',
)
row.status = 'cancelled'
row.finished_at = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return EntityResponse(item=_to_job(row))
+120
View File
@@ -0,0 +1,120 @@
import re
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import NetObjectModel
from app.schemas import EntityResponse, ListResponse, NetObject, NetObjectCreate, NetObjectUpdate
from app.services.inventory import _to_net_object
router = APIRouter(prefix='/objects', tags=['objects'])
def _slugify(name: str) -> str:
slug = name.lower().strip()
slug = re.sub(r'[^\w\s-]', '', slug)
slug = re.sub(r'[\s_-]+', '-', slug)
return slug.strip('-')
@router.get('', response_model=ListResponse[NetObject])
def list_objects(db: Session = Depends(get_db)):
rows = db.query(NetObjectModel).all()
return ListResponse(count=len(rows), items=[_to_net_object(r) for r in rows])
@router.get('/{object_id}', response_model=EntityResponse[NetObject])
def get_object(object_id: str, db: Session = Depends(get_db)):
row = db.query(NetObjectModel).filter(NetObjectModel.id == object_id).first()
if not row:
raise HTTPException(status_code=404, detail='Object not found')
return EntityResponse(item=_to_net_object(row))
@router.post('', response_model=EntityResponse[NetObject], status_code=201)
def create_object(body: NetObjectCreate, db: Session = Depends(get_db)):
if body.parent_id:
parent = db.query(NetObjectModel).filter(NetObjectModel.id == body.parent_id).first()
if not parent:
raise HTTPException(status_code=422, detail=f'parent_id {body.parent_id!r} not found')
now = datetime.now(timezone.utc)
obj = NetObjectModel(
id=f'obj_{uuid4().hex[:8]}',
name=body.name,
slug=_slugify(body.name),
type=body.type,
parent_id=body.parent_id,
status=body.status,
vendor=body.vendor,
model=body.model,
serial=body.serial,
notes=body.notes,
created_at=now,
updated_at=now,
)
db.add(obj)
db.commit()
db.refresh(obj)
return EntityResponse(item=_to_net_object(obj))
@router.put('/{object_id}', response_model=EntityResponse[NetObject])
def replace_object(object_id: str, body: NetObjectCreate, db: Session = Depends(get_db)):
row = db.query(NetObjectModel).filter(NetObjectModel.id == object_id).first()
if not row:
raise HTTPException(status_code=404, detail='Object not found')
if body.parent_id and body.parent_id != row.parent_id:
parent = db.query(NetObjectModel).filter(NetObjectModel.id == body.parent_id).first()
if not parent:
raise HTTPException(status_code=422, detail=f'parent_id {body.parent_id!r} not found')
row.name = body.name
row.slug = _slugify(body.name)
row.type = body.type
row.parent_id = body.parent_id
row.status = body.status
row.vendor = body.vendor
row.model = body.model
row.serial = body.serial
row.notes = body.notes
row.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return EntityResponse(item=_to_net_object(row))
@router.patch('/{object_id}', response_model=EntityResponse[NetObject])
def update_object(object_id: str, body: NetObjectUpdate, db: Session = Depends(get_db)):
row = db.query(NetObjectModel).filter(NetObjectModel.id == object_id).first()
if not row:
raise HTTPException(status_code=404, detail='Object not found')
data = body.model_dump(exclude_unset=True)
if 'parent_id' in data and data['parent_id']:
parent = db.query(NetObjectModel).filter(NetObjectModel.id == data['parent_id']).first()
if not parent:
raise HTTPException(status_code=422, detail=f'parent_id {data["parent_id"]!r} not found')
if 'name' in data:
row.slug = _slugify(data['name'])
for field, value in data.items():
setattr(row, field, value)
row.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return EntityResponse(item=_to_net_object(row))
@router.delete('/{object_id}', status_code=204)
def delete_object(object_id: str, db: Session = Depends(get_db)):
row = db.query(NetObjectModel).filter(NetObjectModel.id == object_id).first()
if not row:
raise HTTPException(status_code=404, detail='Object not found')
children = db.query(NetObjectModel).filter(NetObjectModel.parent_id == object_id).count()
if children:
raise HTTPException(
status_code=409,
detail=f'Cannot delete: object has {children} child object(s). Remove or re-parent them first.',
)
db.delete(row)
db.commit()
+106
View File
@@ -0,0 +1,106 @@
import json
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import NetObjectModel, RelationModel
from app.schemas import (
EntityResponse, ListResponse, Relation,
RelationCreate, RelationUpdate,
)
from app.services.inventory import _to_relation
router = APIRouter(prefix='/relations', tags=['relations'])
@router.get('', response_model=ListResponse[Relation])
def list_relations(
source_id: str | None = None,
target_id: str | None = None,
layer: str | None = None,
type: str | None = Query(default=None),
min_confidence: float | None = None,
status: str | None = None,
db: Session = Depends(get_db),
):
q = db.query(RelationModel)
if source_id:
q = q.filter(RelationModel.source_id == source_id)
if target_id:
q = q.filter(RelationModel.target_id == target_id)
if layer:
q = q.filter(RelationModel.layer == layer)
if type:
q = q.filter(RelationModel.type == type)
if min_confidence is not None:
q = q.filter(RelationModel.confidence >= min_confidence)
if status:
q = q.filter(RelationModel.status == status)
rows = q.all()
return ListResponse(count=len(rows), items=[_to_relation(r) for r in rows])
@router.get('/{relation_id}', response_model=EntityResponse[Relation])
def get_relation(relation_id: str, db: Session = Depends(get_db)):
row = db.query(RelationModel).filter(RelationModel.id == relation_id).first()
if not row:
raise HTTPException(status_code=404, detail='Relation not found')
return EntityResponse(item=_to_relation(row))
@router.post('', response_model=EntityResponse[Relation], status_code=201)
def create_relation(body: RelationCreate, db: Session = Depends(get_db)):
for fk, val in [('source_id', body.source_id), ('target_id', body.target_id)]:
if not db.query(NetObjectModel).filter(NetObjectModel.id == val).first():
raise HTTPException(status_code=422, detail=f'{fk} {val!r} not found')
now = datetime.now(timezone.utc)
row = RelationModel(
id=f'rel_{uuid4().hex[:8]}',
type=body.type,
layer=body.layer,
source_id=body.source_id,
target_id=body.target_id,
direction=body.direction,
source=body.source,
confidence=body.confidence,
status=body.status,
attributes_json=json.dumps(body.attributes),
created_at=now,
updated_at=now,
)
db.add(row)
db.commit()
db.refresh(row)
return EntityResponse(item=_to_relation(row))
@router.patch('/{relation_id}', response_model=EntityResponse[Relation])
def update_relation(relation_id: str, body: RelationUpdate, db: Session = Depends(get_db)):
row = db.query(RelationModel).filter(RelationModel.id == relation_id).first()
if not row:
raise HTTPException(status_code=404, detail='Relation not found')
data = body.model_dump(exclude_unset=True)
for fk in ('source_id', 'target_id'):
if fk in data and data[fk]:
if not db.query(NetObjectModel).filter(NetObjectModel.id == data[fk]).first():
raise HTTPException(status_code=422, detail=f'{fk} {data[fk]!r} not found')
if 'attributes' in data:
data['attributes_json'] = json.dumps(data.pop('attributes'))
for field, value in data.items():
setattr(row, field, value)
row.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(row)
return EntityResponse(item=_to_relation(row))
@router.delete('/{relation_id}', status_code=204)
def delete_relation(relation_id: str, db: Session = Depends(get_db)):
row = db.query(RelationModel).filter(RelationModel.id == relation_id).first()
if not row:
raise HTTPException(status_code=404, detail='Relation not found')
db.delete(row)
db.commit()
View File
+120
View File
@@ -0,0 +1,120 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import Optional
from app.auth import (
ACCESS_TOKEN_EXPIRE_MINUTES,
Token,
UserOut,
authenticate_user,
create_access_token,
get_current_active_user,
get_user_by_username,
hash_password,
require_role,
)
from app.database import get_db
from app.models import UserModel
router = APIRouter(prefix="/auth", tags=["auth"])
class UserCreate(BaseModel):
username: str
password: str
email: Optional[str] = None
role: str = "viewer"
class PasswordChange(BaseModel):
current_password: str
new_password: str
# ---------- Endpoints ----------
@router.post("/token", response_model=Token)
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db),
):
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(
{"sub": user.username, "role": user.role},
timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
)
return Token(access_token=access_token)
@router.get("/me", response_model=UserOut)
def me(current_user: UserModel = Depends(get_current_active_user)):
return current_user
@router.patch("/me/password")
def change_my_password(
body: PasswordChange,
current_user: UserModel = Depends(get_current_active_user),
db: Session = Depends(get_db),
):
from app.auth import verify_password
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(status_code=400, detail="Current password is incorrect")
current_user.hashed_password = hash_password(body.new_password)
current_user.force_password_change = False
db.commit()
return {"detail": "Password updated"}
@router.get("/users", response_model=list[UserOut])
def list_users(
_: UserModel = Depends(require_role("admin")),
db: Session = Depends(get_db),
):
return db.query(UserModel).order_by(UserModel.id).all()
@router.post("/users", response_model=UserOut, status_code=201)
def create_user(
body: UserCreate,
_: UserModel = Depends(require_role("admin")),
db: Session = Depends(get_db),
):
if get_user_by_username(db, body.username):
raise HTTPException(status_code=409, detail="Username already exists")
user = UserModel(
username=body.username,
hashed_password=hash_password(body.password),
email=body.email,
role=body.role,
disabled=False,
force_password_change=False,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.patch("/users/{username}/disable")
def disable_user(
username: str,
_: UserModel = Depends(require_role("admin")),
db: Session = Depends(get_db),
):
user = get_user_by_username(db, username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.disabled = True
db.commit()
return {"detail": f"User {username!r} disabled"}
+243
View File
@@ -0,0 +1,243 @@
from datetime import datetime
from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar
from pydantic import BaseModel, Field
T = TypeVar('T')
JobStatus = Literal['pending', 'running', 'completed', 'failed', 'cancelled']
ObjectStatus = Literal['active', 'planned', 'inactive', 'retired', 'unknown']
ObjectType = Literal[
'site', 'area', 'rack', 'physical_host', 'vm', 'lxc', 'container',
'app', 'switch', 'firewall', 'router', 'ap', 'camera', 'storage', 'other'
]
RelationType = Literal[
'contains', 'mounted_in', 'attached_to', 'connects_to', 'uplink_to',
'routes_via', 'bridges_to', 'depends_on', 'hosts_service', 'managed_by'
]
RelationLayer = Literal['physical', 'network', 'logical', 'service']
RelationSource = Literal['manual', 'snmp', 'lldp', 'arp', 'nmap', 'inference', 'import']
RelationStatus = Literal['active', 'stale', 'planned', 'deleted']
RelationDirection = Literal['forward']
class HealthCheck(BaseModel):
status: Literal['pass', 'warn', 'fail']
latency_ms: Optional[float] = None
detail: Optional[str] = None
class HealthResponse(BaseModel):
status: Literal['pass', 'warn', 'fail']
service: str
version: Optional[str] = None
time: datetime
uptime_s: Optional[float] = None
checks: Dict[str, HealthCheck] = Field(default_factory=dict)
# ---------------------------------------------------------------------------
# NetObject
# ---------------------------------------------------------------------------
class NetObject(BaseModel):
id: str
name: str
slug: Optional[str] = None
type: ObjectType
parent_id: Optional[str] = None
status: ObjectStatus
vendor: Optional[str] = None
model: Optional[str] = None
serial: Optional[str] = None
notes: Optional[str] = None
created_at: datetime
updated_at: datetime
class NetObjectCreate(BaseModel):
name: str
type: ObjectType
parent_id: Optional[str] = None
status: ObjectStatus = 'active'
vendor: Optional[str] = None
model: Optional[str] = None
serial: Optional[str] = None
notes: Optional[str] = None
class NetObjectUpdate(BaseModel):
name: Optional[str] = None
type: Optional[ObjectType] = None
parent_id: Optional[str] = None
status: Optional[ObjectStatus] = None
vendor: Optional[str] = None
model: Optional[str] = None
serial: Optional[str] = None
notes: Optional[str] = None
# ---------------------------------------------------------------------------
# IPAddress / Subnet / Link (read-only for now)
# ---------------------------------------------------------------------------
class IPAddress(BaseModel):
id: str
address: str
version: Literal[4, 6]
subnet_id: Optional[str] = None
object_id: Optional[str] = None
interface_id: Optional[str] = None
hostname: Optional[str] = None
source: Literal['manual', 'snmp', 'arp', 'lldp', 'nmap', 'import', 'other']
status: Literal['assigned', 'reserved', 'free', 'unknown'] = 'unknown'
class Subnet(BaseModel):
id: str
name: str
cidr: str
gateway: Optional[str] = None
vlan_id: Optional[int] = None
notes: Optional[str] = None
class Link(BaseModel):
id: str
source_object_id: str
target_object_id: str
source_interface_id: Optional[str] = None
target_interface_id: Optional[str] = None
type: Literal['physical', 'logical', 'hosted_on', 'member_of', 'uplink', 'other']
confidence: float = 1.0
source: Literal['manual', 'snmp', 'lldp', 'arp', 'import', 'inference']
# ---------------------------------------------------------------------------
# Relation
# ---------------------------------------------------------------------------
class Relation(BaseModel):
id: str
type: RelationType
layer: RelationLayer
source_id: str
target_id: str
direction: RelationDirection = 'forward'
source: RelationSource
confidence: float = Field(ge=0, le=1, default=1.0)
status: RelationStatus = 'active'
attributes: Dict[str, Any] = Field(default_factory=dict)
created_at: datetime
updated_at: datetime
class RelationCreate(BaseModel):
type: RelationType
layer: RelationLayer
source_id: str
target_id: str
direction: RelationDirection = 'forward'
source: RelationSource = 'manual'
confidence: float = Field(ge=0, le=1, default=1.0)
status: RelationStatus = 'active'
attributes: Dict[str, Any] = Field(default_factory=dict)
class RelationUpdate(BaseModel):
type: Optional[RelationType] = None
layer: Optional[RelationLayer] = None
source_id: Optional[str] = None
target_id: Optional[str] = None
direction: Optional[RelationDirection] = None
source: Optional[RelationSource] = None
confidence: Optional[float] = Field(ge=0, le=1, default=None)
status: Optional[RelationStatus] = None
attributes: Optional[Dict[str, Any]] = None
# ---------------------------------------------------------------------------
# Tree / Context
# ---------------------------------------------------------------------------
class InventoryTreeNode(BaseModel):
id: str
name: str
type: ObjectType
status: ObjectStatus
badges: List[str] = Field(default_factory=list)
children_count: int = 0
children: List['InventoryTreeNode'] = Field(default_factory=list)
class NodeContextRelations(BaseModel):
incoming: List[Relation] = Field(default_factory=list)
outgoing: List[Relation] = Field(default_factory=list)
class NodeContext(BaseModel):
item: NetObject
parent: Optional[NetObject] = None
children: List[NetObject] = Field(default_factory=list)
relations: NodeContextRelations = Field(default_factory=NodeContextRelations)
# ---------------------------------------------------------------------------
# Jobs / Discovery
# ---------------------------------------------------------------------------
class Job(BaseModel):
id: str
type: str
status: JobStatus
progress: int = 0
message: Optional[str] = None
error: Optional[str] = None
payload: Optional[Dict[str, Any]] = None
result: Optional[Dict[str, Any]] = None
created_at: datetime
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
class DiscoveryProfile(BaseModel):
id: str
name: str
strategy: Literal['fast', 'balanced', 'deep', 'custom']
use_icmp: bool = True
use_arp: bool = True
use_snmp: bool = False
use_lldp: bool = False
use_portscan: bool = False
notes: Optional[str] = None
class DiscoveryRun(BaseModel):
id: str
profile_id: str
subnet_ids: List[str] = Field(default_factory=list)
job_id: Optional[str] = None
status: JobStatus
summary: Optional[Dict[str, Any]] = None
created_at: datetime
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
# ---------------------------------------------------------------------------
# Generic wrappers
# ---------------------------------------------------------------------------
class ListResponse(BaseModel, Generic[T]):
count: int
items: List[T]
page: Optional[int] = None
page_size: Optional[int] = None
total: Optional[int] = None
class EntityResponse(BaseModel, Generic[T]):
item: T
InventoryTreeNode.model_rebuild()
+131
View File
@@ -0,0 +1,131 @@
import json
from collections import defaultdict
from sqlalchemy.orm import Session
from app.models import NetObjectModel, RelationModel
from app.schemas import InventoryTreeNode, NetObject, NodeContext, NodeContextRelations, Relation
def _to_net_object(m: NetObjectModel) -> NetObject:
return NetObject(
id=m.id, name=m.name, slug=m.slug, type=m.type,
parent_id=m.parent_id, status=m.status, vendor=m.vendor,
model=m.model, serial=m.serial, notes=m.notes,
created_at=m.created_at, updated_at=m.updated_at,
)
def _to_relation(m: RelationModel) -> Relation:
return Relation(
id=m.id, type=m.type, layer=m.layer,
source_id=m.source_id, target_id=m.target_id,
direction=m.direction, source=m.source,
confidence=m.confidence, status=m.status,
attributes=json.loads(m.attributes_json or "{}"),
created_at=m.created_at, updated_at=m.updated_at,
)
def get_object_map(db: Session) -> dict[str, NetObject]:
return {r.id: _to_net_object(r) for r in db.query(NetObjectModel).all()}
def get_children_map(db: Session) -> dict[str, list[NetObject]]:
object_map = get_object_map(db)
rels = db.query(RelationModel).filter(
RelationModel.type == "contains",
RelationModel.status == "active",
).all()
children: dict[str, list[NetObject]] = defaultdict(list)
for rel in rels:
if rel.target_id in object_map:
children[rel.source_id].append(object_map[rel.target_id])
return children
def get_badges(db: Session, object_id: str) -> list[str]:
obj = get_object_map(db).get(object_id)
if not obj:
return []
badges: list[str] = []
if obj.type == "physical_host":
badges.append("virtualization-host")
if obj.type in {"vm", "router", "firewall"}:
badges.append("network-node")
outgoing = db.query(RelationModel).filter(
RelationModel.source_id == object_id,
RelationModel.status == "active",
).all()
if any(r.type == "routes_via" for r in outgoing):
badges.append("gateway-dependent")
if any(r.type in {"routes_via", "bridges_to"} for r in outgoing) \
or obj.name.lower().startswith("openwrt"):
badges.append("gateway")
return badges
def build_tree_node(db: Session, object_id: str, depth: int | None = None) -> InventoryTreeNode:
om = get_object_map(db)
cm = get_children_map(db)
obj = om[object_id]
ch = cm.get(object_id, [])
child_nodes = []
if depth is None or depth > 0:
nd = None if depth is None else depth - 1
child_nodes = [build_tree_node(db, c.id, nd) for c in ch]
return InventoryTreeNode(
id=obj.id, name=obj.name, type=obj.type, status=obj.status,
badges=get_badges(db, obj.id),
children_count=len(ch),
children=child_nodes,
)
def build_inventory_tree(
db: Session, root_id: str | None = None, depth: int | None = None
) -> list[InventoryTreeNode]:
om = get_object_map(db)
if root_id:
return [build_tree_node(db, root_id, depth)] if root_id in om else []
roots = db.query(NetObjectModel).filter(NetObjectModel.parent_id == None).all() # noqa: E711
return [build_tree_node(db, r.id, depth) for r in roots]
def build_node_context(db: Session, item_id: str) -> NodeContext | None:
row = db.query(NetObjectModel).filter(NetObjectModel.id == item_id).first()
if not row:
return None
item = _to_net_object(row)
parent = None
if item.parent_id:
p = db.query(NetObjectModel).filter(NetObjectModel.id == item.parent_id).first()
parent = _to_net_object(p) if p else None
children = get_children_map(db).get(item_id, [])
inc = [_to_relation(r) for r in db.query(RelationModel).filter(
RelationModel.target_id == item_id, RelationModel.status == "active").all()]
out = [_to_relation(r) for r in db.query(RelationModel).filter(
RelationModel.source_id == item_id, RelationModel.status == "active").all()]
return NodeContext(
item=item, parent=parent, children=children,
relations=NodeContextRelations(incoming=inc, outgoing=out),
)
def get_node_relations(
db: Session, item_id: str,
layer: str | None = None,
rel_type: str | None = None,
direction: str = "both",
) -> list[Relation]:
result: list[Relation] = []
base = db.query(RelationModel).filter(RelationModel.status == "active")
if direction in {"incoming", "both"}:
result += [_to_relation(r) for r in base.filter(RelationModel.target_id == item_id).all()]
if direction in {"outgoing", "both"}:
result += [_to_relation(r) for r in base.filter(RelationModel.source_id == item_id).all()]
if layer:
result = [r for r in result if r.layer == layer]
if rel_type:
result = [r for r in result if r.type == rel_type]
return result
+34
View File
@@ -0,0 +1,34 @@
from datetime import datetime, timezone
from app.schemas import DiscoveryProfile, DiscoveryRun, Job, NetObject, Relation
NOW = datetime.now(timezone.utc)
OBJECTS = [
NetObject(id='obj_1', name='Site HQ', slug='site-hq', type='site', status='active', created_at=NOW, updated_at=NOW),
NetObject(id='obj_2', name='Rack A', slug='rack-a', type='rack', parent_id='obj_1', status='active', created_at=NOW, updated_at=NOW),
NetObject(id='obj_3', name='Proxmox Host', slug='proxmox-host', type='physical_host', parent_id='obj_2', status='active', vendor='Lenovo', model='ThinkCentre', created_at=NOW, updated_at=NOW),
NetObject(id='obj_4', name='OpenWRT VM', slug='openwrt-vm', type='vm', parent_id='obj_3', status='active', created_at=NOW, updated_at=NOW),
NetObject(id='obj_5', name='Core Switch', slug='core-switch', type='switch', parent_id='obj_2', status='active', created_at=NOW, updated_at=NOW),
]
RELATIONS = [
Relation(id='rel_1', type='contains', layer='physical', source_id='obj_1', target_id='obj_2', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_2', type='contains', layer='physical', source_id='obj_2', target_id='obj_3', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_3', type='contains', layer='physical', source_id='obj_3', target_id='obj_4', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_4', type='contains', layer='physical', source_id='obj_2', target_id='obj_5', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_5', type='routes_via', layer='network', source_id='obj_3', target_id='obj_4', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
Relation(id='rel_6', type='connects_to', layer='network', source_id='obj_4', target_id='obj_5', source='manual', confidence=0.9, created_at=NOW, updated_at=NOW),
]
JOBS = [
Job(id='job_1', type='discovery.scan', status='pending', progress=0, created_at=NOW, payload={'subnet_ids': ['subnet_1'], 'profile_id': 'profile_1'})
]
DISCOVERY_PROFILES = [
DiscoveryProfile(id='profile_1', name='Fast LAN', strategy='fast', use_icmp=True, use_arp=True)
]
DISCOVERY_RUNS = [
DiscoveryRun(id='disc_1', profile_id='profile_1', subnet_ids=['subnet_1'], job_id='job_1', status='pending', created_at=NOW)
]
+7
View File
@@ -0,0 +1,7 @@
fastapi
uvicorn[standard]
python-jose[cryptography]
passlib[bcrypt]
python-multipart
sqlalchemy
bcrypt==4.0.1
+33
View File
@@ -0,0 +1,33 @@
import sys
sys.path.insert(0, "/opt/NetMapper2026/backend")
from datetime import datetime, timezone
from app.database import SessionLocal, engine, Base
from app.models import NetObjectModel, RelationModel
Base.metadata.create_all(bind=engine)
db = SessionLocal()
NOW = datetime.now(timezone.utc)
if db.query(NetObjectModel).count() == 0:
db.add_all([
NetObjectModel(id='obj_1', name='Site HQ', slug='site-hq', type='site', status='active', created_at=NOW, updated_at=NOW),
NetObjectModel(id='obj_2', name='Rack A', slug='rack-a', type='rack', parent_id='obj_1', status='active', created_at=NOW, updated_at=NOW),
NetObjectModel(id='obj_3', name='Proxmox Host', slug='proxmox-host', type='physical_host', parent_id='obj_2', status='active', vendor='Lenovo', model='ThinkCentre', created_at=NOW, updated_at=NOW),
NetObjectModel(id='obj_4', name='OpenWRT VM', slug='openwrt-vm', type='vm', parent_id='obj_3', status='active', created_at=NOW, updated_at=NOW),
NetObjectModel(id='obj_5', name='Core Switch', slug='core-switch', type='switch', parent_id='obj_2', status='active', created_at=NOW, updated_at=NOW),
])
db.add_all([
RelationModel(id='rel_1', type='contains', layer='physical', source_id='obj_1', target_id='obj_2', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
RelationModel(id='rel_2', type='contains', layer='physical', source_id='obj_2', target_id='obj_3', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
RelationModel(id='rel_3', type='contains', layer='physical', source_id='obj_3', target_id='obj_4', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
RelationModel(id='rel_4', type='contains', layer='physical', source_id='obj_2', target_id='obj_5', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
RelationModel(id='rel_5', type='routes_via', layer='network', source_id='obj_3', target_id='obj_4', source='manual', confidence=1.0, created_at=NOW, updated_at=NOW),
RelationModel(id='rel_6', type='connects_to', layer='network', source_id='obj_4', target_id='obj_5', source='manual', confidence=0.9, created_at=NOW, updated_at=NOW),
])
db.commit()
print("Seed completato: 5 oggetti, 6 relazioni inseriti.")
else:
print(f"DB gia' popolato ({db.query(NetObjectModel).count()} oggetti), skip.")
db.close()
+54
View File
@@ -0,0 +1,54 @@
version: "3.9"
services:
postgres:
image: postgres:16
environment:
POSTGRES_DB: netmapper
POSTGRES_USER: netmapper
POSTGRES_PASSWORD: netmapper
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
redis:
image: redis:7
ports:
- "6379:6379"
backend:
image: python:3.12-slim
working_dir: /app
command: sh -c "pip install -r requirements.txt && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
volumes:
- ../../backend:/app
ports:
- "8000:8000"
depends_on:
- postgres
- redis
worker:
image: python:3.12-slim
working_dir: /app
command: sh -c "pip install -r requirements.txt && python app/main.py"
volumes:
- ../../worker:/app
depends_on:
- redis
- backend
frontend:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 5173"
volumes:
- ../../frontend:/app
ports:
- "5173:5173"
depends_on:
- backend
volumes:
postgres_data:
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=NetMapper Control Agent
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/NetMapper2026/agent
ExecStart=/opt/NetMapper2026/agent/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8001
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=NetMapper Backend
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/NetMapper2026/backend
ExecStart=/opt/NetMapper2026/backend/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=NetMapper Frontend
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/NetMapper2026/frontend
ExecStart=/usr/bin/npm run dev -- --host 0.0.0.0 --port 5173
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=NetMapper Worker
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/NetMapper2026/worker
ExecStart=/opt/NetMapper2026/worker/.venv/bin/python app/main.py
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+5 -3
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -13,8 +13,10 @@
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"vite": "^5.4.8", "vite": "^5.4.8"
"@vitejs/plugin-react": "^4.3.2"
} }
} }
+188
View File
@@ -0,0 +1,188 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #f7f6f2;
--color-surface: #ffffff;
--color-surface-offset: #f3f0ec;
--color-divider: #dcd9d5;
--color-text: #28251d;
--color-text-muted: #7a7974;
--color-primary: #01696f;
--color-primary-hover: #0c4e54;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--font-body: 'Inter', 'Segoe UI', sans-serif;
}
html, body, #root {
height: 100%;
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-bg);
}
.app {
display: grid;
grid-template-columns: 220px 1fr;
height: 100%;
}
.sidebar {
background: var(--color-surface);
border-right: 1px solid var(--color-divider);
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.sidebar-title {
font-weight: 700;
font-size: 1rem;
margin-bottom: var(--space-4);
}
.nav-item {
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s;
font-size: 0.9rem;
}
.nav-item:hover {
background: var(--color-surface-offset);
}
.nav-item.active {
background: var(--color-primary);
color: #fff;
}
.main {
padding: var(--space-6);
overflow: auto;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
}
.page-title {
font-size: 1.25rem;
font-weight: 600;
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-divider);
border-radius: var(--radius-md);
padding: var(--space-4);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.table th, .table td {
text-align: left;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--color-divider);
}
.table th {
font-weight: 600;
color: var(--color-text-muted);
}
.btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
border: 1px solid var(--color-divider);
background: var(--color-surface);
cursor: pointer;
font-size: 0.9rem;
transition: background 0.15s, border-color 0.15s;
}
.btn:hover {
background: var(--color-surface-offset);
}
.btn-primary {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.btn-primary:hover {
background: var(--color-primary-hover);
}
.empty {
color: var(--color-text-muted);
padding: var(--space-8);
text-align: center;
}
.actions {
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
}
.status {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
}
.status-active {
background: #d9efe2;
color: #216c43;
}
.status-failed {
background: #f8d7da;
color: #8a1f2d;
}
.status-inactive {
background: #ececec;
color: #555;
}
.status-pending {
background: #fff3cd;
color: #8a6d1f;
}
.alert {
margin-bottom: var(--space-4);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-sm);
background: #f8d7da;
color: #8a1f2d;
border: 1px solid #f1b8c0;
}
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { InventoryPage } from './pages/InventoryPage';
import { LoginPage } from './pages/LoginPage';
import { getToken, clearSession } from './lib/session';
import './app-shell.css';
type TabKey = 'overview' | 'inventory' | 'discovery' | 'topology' | 'options';
const tabs = [
{ key: 'overview', label: 'Overview', description: 'Stato generale del workspace NetMapper.' },
{ key: 'inventory', label: 'Inventory', description: 'Tree fisico/logico e navigazione tra oggetti.' },
{ key: 'discovery', label: 'Discovery', description: 'Pipeline e job di raccolta dati.' },
{ key: 'topology', label: 'Topology', description: 'Vista futura della mappa multilivello.' },
{ key: 'options', label: 'Options', description: 'Utenti, impostazioni, servizi di sistema e sicurezza.' },
] as const;
function ShellCard({ title, children, tone = 'default' }: { title: string; children: React.ReactNode; tone?: 'default' | 'accent' | 'warning' }) {
return <section className={`shell-card ${tone}`}><div className="shell-card-header"><h3>{title}</h3></div><div className="shell-card-body">{children}</div></section>;
}
function OverviewTab() { return <div className="shell-grid"><ShellCard title="Workspace status" tone="accent"><ul className="shell-list"><li>Frontend inventory collegato al backend.</li><li>Tree gerarchico e context panel attivi.</li><li>Navigazione tra parent, children e relazioni disponibile.</li></ul></ShellCard><ShellCard title="Milestone attive"><ul className="shell-list"><li>Reintegro layout persistente completato.</li><li>Roadmap di security e options introdotta.</li><li>Preparazione vista topology multilivello.</li></ul></ShellCard><ShellCard title="Quick notes" tone="warning"><ul className="shell-list"><li>I moduli servizi confluiranno nel menu Options.</li><li>L'accesso dovrà essere autenticato prima delle integrazioni remote.</li><li>Serve hardening progressivo di sessione e permessi.</li></ul></ShellCard></div>; }
function DiscoveryTab() { return <div className="shell-grid two-columns"><ShellCard title="Discovery pipeline"><ul className="shell-list"><li>Seed scan subnet.</li><li>Classificazione host e object binding.</li><li>Correlazione con relazioni inventory.</li></ul></ShellCard><ShellCard title="Job queue"><ul className="shell-list"><li>SNMP collector.</li><li>LLDP ingestion.</li><li>Port mapping inference.</li></ul></ShellCard></div>; }
function TopologyTab() { return <div className="shell-grid two-columns"><ShellCard title="Topology roadmap"><ul className="shell-list"><li>Vista fisica dispositivi.</li><li>Vista logica host / VM / container / app.</li><li>Layer networking e dipendenze.</li></ul></ShellCard><ShellCard title="Planned interactions"><ul className="shell-list"><li>Drag &amp; drop dei contenitori.</li><li>Link fisici e logici cliccabili.</li><li>Focus node e highlight dei vicini.</li></ul></ShellCard></div>; }
function OptionsTab() { return <div className="shell-stack"><div className="shell-grid two-columns"><ShellCard title="User management" tone="accent"><ul className="shell-list"><li>Utenti locali e ruoli applicativi.</li><li>Disattivazione account inattivi o non più autorizzati.</li><li>Storico accessi e audit trail amministrativo.</li></ul></ShellCard><ShellCard title="System settings"><ul className="shell-list"><li>Parametri workspace e branding dell'istanza.</li><li>Configurazione discovery, inventory e policy future.</li><li>Feature flags per moduli sperimentali.</li></ul></ShellCard></div><div className="shell-grid two-columns"><ShellCard title="Services backlog"><ul className="shell-list"><li>La sezione Servizi viene assorbita nel menu Options.</li><li>Qui confluiranno terminale remoto, job runner e integrazioni di sistema.</li><li>Ogni funzione sensibile dovrà essere protetta da permessi espliciti.</li></ul></ShellCard><ShellCard title="Security roadmap" tone="warning"><ul className="shell-list"><li>Login obbligatorio per tutte le viste non pubbliche.</li><li>MFA per admin e per funzioni ad alto impatto.</li><li>Controlli server-side su ogni richiesta, deny-by-default e audit centralizzato.</li></ul></ShellCard></div></div>; }
function AppShell() {
const [activeTab, setActiveTab] = useState<TabKey>('inventory');
const currentTab = tabs.find((t) => t.key === activeTab)!;
return <div className="app-shell"><aside className="app-sidebar"><div className="app-brand"><div className="app-brand-mark">NM</div><div><h1>NetMapper</h1><p>Homelab workspace</p></div></div><nav className="app-nav">{tabs.map((tab) => <button key={tab.key} className={`app-nav-item ${activeTab === tab.key ? 'active' : ''}`} onClick={() => setActiveTab(tab.key)}><span>{tab.label}</span><small>{tab.description}</small></button>)}</nav><div className="app-sidebar-footer"><span className="shell-pill">MVP</span><span className="shell-pill">Inventory online</span><span className="shell-pill">Options added</span></div></aside><main className="app-main"><header className="app-header"><div><h2>{currentTab.label}</h2><p>{currentTab.description}</p></div><div className="app-header-actions"><span className="shell-pill">Book of truth</span><span className="shell-pill">Auth active</span></div></header><section style={{ display: activeTab === 'overview' ? 'block' : 'none' }}><OverviewTab /></section><section style={{ display: activeTab === 'inventory' ? 'block' : 'none' }}><InventoryPage /></section><section style={{ display: activeTab === 'discovery' ? 'block' : 'none' }}><DiscoveryTab /></section><section style={{ display: activeTab === 'topology' ? 'block' : 'none' }}><TopologyTab /></section><section style={{ display: activeTab === 'options' ? 'block' : 'none' }}><OptionsTab /></section></main></div>;
}
export default function App() {
const [loggedIn, setLoggedIn] = useState<boolean>(false);
useEffect(() => {
const t = getToken();
if (t) setLoggedIn(true);
}, []);
const handleLogin = () => setLoggedIn(true);
const handleLogout = () => {
clearSession();
setLoggedIn(false);
};
if (!loggedIn) return <LoginPage onLogin={handleLogin} />;
return <><button onClick={handleLogout} className="logout-button">Logout</button><AppShell /></>;
}
+36
View File
@@ -0,0 +1,36 @@
:root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background: #0b1020; }
body { margin: 0; background: linear-gradient(180deg, #0b1020 0%, #111827 100%); color: #e5e7eb; }
#root { min-height: 100vh; }
.logout-button { position: fixed; top: 16px; right: 16px; z-index: 50; border: 1px solid rgba(255,255,255,0.08); background: rgba(17,24,39,0.88); color: #e5e7eb; border-radius: 999px; padding: 10px 14px; }
.app-shell { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 100vh; }
.app-sidebar { border-right: 1px solid rgba(255,255,255,0.08); background: rgba(2, 6, 23, 0.75); backdrop-filter: blur(14px); padding: 20px; display: flex; flex-direction: column; gap: 20px; }
.app-brand { display: flex; align-items: center; gap: 12px; }
.app-brand-mark { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; background: linear-gradient(135deg, #1d4ed8, #2563eb); color: white; font-weight: 700; }
.app-brand h1, .app-header h2, .shell-card h3 { margin: 0; }
.app-brand p, .app-header p { margin: 4px 0 0; color: #94a3b8; }
.app-nav { display: flex; flex-direction: column; gap: 10px; }
.app-nav-item { border: 1px solid rgba(255,255,255,0.08); background: rgba(255,255,255,0.03); border-radius: 12px; padding: 12px 14px; color: #e5e7eb; text-align: left; cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
.app-nav-item.active { background: rgba(37, 99, 235, 0.16); border-color: rgba(96, 165, 250, 0.35); }
.app-nav-item small { color: #94a3b8; }
.app-sidebar-footer, .app-header-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.app-main { padding: 20px; }
.app-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.shell-pill { display: inline-flex; align-items: center; padding: 6px 10px; border-radius: 999px; background: rgba(255,255,255,0.08); color: #cbd5e1; font-size: 12px; }
.shell-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.shell-grid.two-columns { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.shell-stack { display: grid; gap: 16px; }
.shell-card { background: rgba(17, 24, 39, 0.88); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 16px; }
.shell-card.accent { border-color: rgba(96, 165, 250, 0.35); }
.shell-card.warning { border-color: rgba(251, 191, 36, 0.35); }
.shell-card-body { margin-top: 12px; }
.shell-list { margin: 0; padding-left: 18px; display: grid; gap: 8px; color: #cbd5e1; }
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.login-card { width: min(100%, 420px); background: rgba(17,24,39,0.92); border: 1px solid rgba(255,255,255,0.08); border-radius: 20px; padding: 24px; }
.login-eyebrow { color: #93c5fd; text-transform: uppercase; letter-spacing: 0.12em; font-size: 12px; }
.login-copy, .login-hint { color: #94a3b8; }
.login-error { color: #fca5a5; }
.login-form { display: grid; gap: 12px; margin-top: 16px; }
.login-form label { display: grid; gap: 6px; }
.login-form input { border: 1px solid rgba(255,255,255,0.1); background: rgba(255,255,255,0.03); border-radius: 12px; padding: 12px 14px; color: #e5e7eb; }
.login-form button { border: 0; border-radius: 12px; padding: 12px 14px; background: #2563eb; color: white; font-weight: 600; }
@media (max-width: 1100px) { .app-shell { grid-template-columns: 1fr; } .app-sidebar { border-right: 0; border-bottom: 1px solid rgba(255,255,255,0.08); } .shell-grid, .shell-grid.two-columns { grid-template-columns: 1fr; } }
+19
View File
@@ -0,0 +1,19 @@
import { Sidebar } from './Sidebar';
import type { ViewKey } from '../types';
interface Props {
current: ViewKey;
onNavigate: (v: ViewKey) => void;
children: React.ReactNode;
}
export function AppLayout({ current, onNavigate, children }: Props) {
return (
<div className="app">
<Sidebar current={current} onChange={onNavigate} />
<main className="main">
{children}
</main>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { ViewKey } from '../types';
interface Props {
current: ViewKey;
onChange: (v: ViewKey) => void;
}
const views: { key: ViewKey; label: string }[] = [
{ key: 'topology', label: 'Topology' },
{ key: 'inventory', label: 'Inventory' },
{ key: 'ips', label: 'IPs' },
{ key: 'discovery', label: 'Discovery Jobs' },
{ key: 'services', label: 'Services' },
];
export function Sidebar({ current, onChange }: Props) {
return (
<aside className="sidebar">
<div className="sidebar-title">NetMapper</div>
{views.map(v => (
<div
key={v.key}
className={`nav-item ${current === v.key ? 'active' : ''}`}
onClick={() => onChange(v.key)}
>
{v.label}
</div>
))}
</aside>
);
}
@@ -0,0 +1,80 @@
import { useMemo, useState } from 'react';
import type { InventoryTreeNode } from '../../types/inventory';
import './inventory.css';
type Props = {
nodes: InventoryTreeNode[];
selectedId: string | null;
onSelect: (id: string) => void;
};
const typeIcon: Record<string, string> = {
site: '🏠',
area: '🗂️',
rack: '🧱',
physical_host: '🖥️',
vm: '📦',
lxc: '📦',
container: '📦',
app: '⚙️',
switch: '🔀',
firewall: '🛡️',
router: '📡',
ap: '📶',
camera: '📷',
storage: '💾',
other: '•',
};
function TreeNode({ node, selectedId, onSelect, level = 0 }: { node: InventoryTreeNode; selectedId: string | null; onSelect: (id: string) => void; level?: number }) {
const [open, setOpen] = useState(level < 1);
const hasChildren = node.children.length > 0 || node.children_count > 0;
const isSelected = selectedId === node.id;
return (
<div className="inventory-tree-node">
<div className={`inventory-tree-row ${isSelected ? 'selected' : ''}`} style={{ paddingLeft: `${level * 16 + 8}px` }}>
<button
className="inventory-tree-toggle"
onClick={() => hasChildren && setOpen((v) => !v)}
aria-label={open ? 'Collapse node' : 'Expand node'}
>
{hasChildren ? (open ? '▾' : '▸') : '·'}
</button>
<button className="inventory-tree-label" onClick={() => onSelect(node.id)}>
<span className="inventory-tree-icon">{typeIcon[node.type] ?? '•'}</span>
<span className="inventory-tree-name">{node.name}</span>
</button>
<div className="inventory-tree-badges">
{node.badges.map((badge) => (
<span key={badge} className="inventory-badge">{badge}</span>
))}
</div>
</div>
{open && node.children.length > 0 && (
<div>
{node.children.map((child) => (
<TreeNode key={child.id} node={child} selectedId={selectedId} onSelect={onSelect} level={level + 1} />
))}
</div>
)}
</div>
);
}
export function InventoryTree({ nodes, selectedId, onSelect }: Props) {
const firstNode = useMemo(() => nodes[0]?.id ?? null, [nodes]);
return (
<section className="inventory-panel inventory-tree-panel">
<div className="inventory-panel-header">
<h2>Inventory</h2>
<span className="inventory-muted">Root: {firstNode ?? 'n/a'}</span>
</div>
<div className="inventory-tree-list">
{nodes.map((node) => (
<TreeNode key={node.id} node={node} selectedId={selectedId} onSelect={onSelect} />
))}
</div>
</section>
);
}
@@ -0,0 +1,75 @@
import type { NodeContext } from '../../types/inventory';
import { RelationNavigator } from './RelationNavigator';
import './inventory.css';
type Props = {
context: NodeContext | null;
loading?: boolean;
onNavigateNode: (id: string) => void;
};
export function NodeContextPanel({ context, loading, onNavigateNode }: Props) {
if (loading) {
return (
<section className="inventory-panel inventory-context-panel">
<div className="inventory-panel-header"><h2>Dettaglio nodo</h2></div>
<p className="inventory-muted">Caricamento...</p>
</section>
);
}
if (!context) {
return (
<section className="inventory-panel inventory-context-panel">
<div className="inventory-panel-header"><h2>Dettaglio nodo</h2></div>
<p className="inventory-muted">Seleziona un nodo dall'inventory.</p>
</section>
);
}
const { item, parent, children, relations } = context;
return (
<section className="inventory-panel inventory-context-panel">
<div className="inventory-panel-header">
<h2>{item.name}</h2>
<span className="inventory-status">{item.type} · {item.status}</span>
</div>
<div className="inventory-context-grid">
<div className="inventory-context-block">
<h3>Physical</h3>
<p><strong>Parent:</strong> {parent ? <button className="inventory-link-button inline" onClick={() => onNavigateNode(parent.id)}>{parent.name}</button> : 'Nessuno'}</p>
<p><strong>Children:</strong> {children.length}</p>
{children.length > 0 && (
<ul className="inventory-list">
{children.map((child) => (
<li key={child.id}>
<button className="inventory-link-button inline" onClick={() => onNavigateNode(child.id)}>{child.name}</button> · {child.type}
</li>
))}
</ul>
)}
</div>
<div className="inventory-context-block">
<h3>Object</h3>
<p><strong>ID:</strong> {item.id}</p>
<p><strong>Vendor:</strong> {item.vendor ?? 'n/a'}</p>
<p><strong>Model:</strong> {item.model ?? 'n/a'}</p>
<p><strong>Notes:</strong> {item.notes ?? 'n/a'}</p>
</div>
</div>
<div className="inventory-context-block">
<h3>Outgoing relations</h3>
<RelationNavigator relations={relations.outgoing} onNavigate={onNavigateNode} />
</div>
<div className="inventory-context-block">
<h3>Incoming relations</h3>
<RelationNavigator relations={relations.incoming} onNavigate={onNavigateNode} />
</div>
</section>
);
}
@@ -0,0 +1,30 @@
import type { Relation } from '../../types/inventory';
import './inventory.css';
type Props = {
relations: Relation[];
onNavigate: (id: string) => void;
};
export function RelationNavigator({ relations, onNavigate }: Props) {
if (relations.length === 0) {
return <p className="inventory-muted">Nessuna relazione navigabile.</p>;
}
return (
<ul className="inventory-list inventory-relation-list">
{relations.map((relation) => {
const targetId = relation.target_id;
return (
<li key={relation.id} className="inventory-relation-item">
<button className="inventory-link-button" onClick={() => onNavigate(targetId)}>
<strong>{relation.type}</strong> <span className="inventory-muted">({relation.layer})</span>
<span className="inventory-relation-arrow"></span>
<span>{targetId}</span>
</button>
</li>
);
})}
</ul>
);
}
@@ -0,0 +1,152 @@
.inventory-layout {
display: grid;
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
gap: 16px;
padding: 16px;
}
.inventory-panel {
background: #111827;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
padding: 16px;
color: #e5e7eb;
}
.inventory-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.inventory-panel-header h2,
.inventory-context-block h3 {
margin: 0;
}
.inventory-tree-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.inventory-tree-row {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px;
border-radius: 8px;
}
.inventory-tree-row.selected {
background: rgba(59, 130, 246, 0.16);
}
.inventory-tree-toggle,
.inventory-tree-label,
.inventory-link-button {
background: transparent;
border: 0;
color: inherit;
cursor: pointer;
}
.inventory-tree-label {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
}
.inventory-tree-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.inventory-tree-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.inventory-badge,
.inventory-status {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.08);
color: #cbd5e1;
font-size: 12px;
}
.inventory-context-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.inventory-context-block {
background: rgba(255,255,255,0.03);
border-radius: 10px;
padding: 12px;
margin-bottom: 12px;
}
.inventory-list {
margin: 8px 0 0;
padding-left: 18px;
}
.inventory-muted {
color: #94a3b8;
}
.inventory-empty,
.inventory-error {
padding: 24px;
color: #cbd5e1;
}
.inventory-link-button.inline {
padding: 0;
color: #93c5fd;
text-decoration: underline;
text-underline-offset: 2px;
}
.inventory-link-button.inline:hover,
.inventory-link-button:hover {
color: #bfdbfe;
}
.inventory-relation-list {
padding-left: 0;
list-style: none;
}
.inventory-relation-item {
margin-top: 8px;
}
.inventory-relation-arrow {
margin: 0 6px;
color: #94a3b8;
}
@media (max-width: 960px) {
.inventory-layout {
grid-template-columns: 1fr;
}
.inventory-context-grid {
grid-template-columns: 1fr;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { getToken, clearSession } from './session';
import type { NetObject, ServiceInfo } from '../types';
const BACKEND = '/backend';
const AGENT = '/api';
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(init.headers as Record<string, string> ?? {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${BACKEND}${path}`, { ...init, headers });
if (res.status === 401) { clearSession(); window.location.reload(); }
return res;
}
export async function apiGet<T>(path: string): Promise<T> {
const res = await apiFetch(path);
if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
return res.json();
}
export async function apiPost<T>(path: string, body?: unknown): Promise<T> {
const res = await apiFetch(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined });
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.detail ?? `POST ${path} failed: ${res.status}`);
}
return res.json();
}
export async function apiPatch<T>(path: string, body?: unknown): Promise<T> {
const res = await apiFetch(path, { method: 'PATCH', body: body ? JSON.stringify(body) : undefined });
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.detail ?? `PATCH ${path} failed: ${res.status}`);
}
return res.json();
}
export async function apiDelete(path: string): Promise<void> {
const res = await apiFetch(path, { method: 'DELETE' });
if (!res.ok) throw new Error(`DELETE ${path} failed: ${res.status}`);
}
// Inventory objects - authenticated via apiGet
export async function fetchObjects(): Promise<NetObject[]> {
return apiGet<NetObject[]>('/objects');
}
// Legacy agent calls (unauthenticated local agent)
export async function fetchServices(): Promise<ServiceInfo[]> {
const res = await fetch(`${AGENT}/services`);
if (!res.ok) throw new Error('Impossibile leggere lo stato dei servizi');
return res.json();
}
export async function serviceAction(name: string, action: 'start' | 'stop' | 'restart') {
const res = await fetch(`${AGENT}/services/${encodeURIComponent(name)}/${action}`, { method: 'POST' });
if (!res.ok) throw new Error(`Azione ${action} fallita su ${name}`);
return res.json();
}
+27
View File
@@ -0,0 +1,27 @@
import { apiFetch } from './api';
import type { EntityResponse, InventoryTreeNode, ListResponse, NodeContext, Relation } from '../types/inventory';
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json() as Promise<T>;
}
async function fetchJson<T>(path: string): Promise<T> {
const response = await apiFetch(path, { cache: 'no-store' });
return handleResponse<T>(response);
}
export async function fetchInventoryTree(): Promise<InventoryTreeNode[]> {
const data = await fetchJson<ListResponse<InventoryTreeNode>>('/inventory/tree');
return data.items;
}
export async function fetchNodeContext(id: string): Promise<NodeContext> {
const data = await fetchJson<EntityResponse<NodeContext>>(`/inventory/nodes/${id}/context`);
return data.item;
}
export async function fetchNodeRelations(id: string): Promise<Relation[]> {
const data = await fetchJson<ListResponse<Relation>>(`/inventory/nodes/${id}/relations`);
return data.items;
}
+34
View File
@@ -0,0 +1,34 @@
const TOKEN_KEY = 'nm_token';
export type SessionUser = {
id: number;
username: string;
email: string | null;
role: string;
disabled: boolean;
force_password_change: boolean;
} | null;
let _token: string | null = null;
let _user: SessionUser = null;
export function getToken(): string | null {
if (_token) return _token;
_token = window.localStorage.getItem(TOKEN_KEY);
return _token;
}
export function setToken(token: string): void {
_token = token;
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearSession(): void {
_token = null;
_user = null;
window.localStorage.removeItem(TOKEN_KEY);
}
export function getSessionUser(): SessionUser { return _user; }
export function setSessionUser(user: SessionUser): void { _user = user; }
export function isAdmin(): boolean { return _user?.role === 'admin'; }
+1 -1
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { App } from './pages/App' import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+15
View File
@@ -0,0 +1,15 @@
import { AppLayout } from '../components/AppLayout';
import type { ViewKey } from '../types';
export function DiscoveryJobsView({ onNavigate }: { onNavigate: (v: ViewKey) => void }) {
return (
<AppLayout current="discovery" onNavigate={onNavigate}>
<header className="header">
<h1 className="page-title">Discovery Jobs</h1>
</header>
<div className="card">
<p className="empty">Job di discovery: prossima implementazione.</p>
</div>
</AppLayout>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { AppLayout } from '../components/AppLayout';
import type { ViewKey } from '../types';
export function IPsView({ onNavigate }: { onNavigate: (v: ViewKey) => void }) {
return (
<AppLayout current="ips" onNavigate={onNavigate}>
<header className="header">
<h1 className="page-title">IPs</h1>
</header>
<div className="card">
<p className="empty">Registro IP: prossima implementazione.</p>
</div>
</AppLayout>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { InventoryTree } from '../components/inventory/InventoryTree';
import { NodeContextPanel } from '../components/inventory/NodeContextPanel';
import { fetchInventoryTree, fetchNodeContext } from '../lib/inventoryApi';
import type { InventoryTreeNode, NodeContext } from '../types/inventory';
import '../components/inventory/inventory.css';
function findFirstNode(nodes: InventoryTreeNode[]): string | null {
for (const node of nodes) {
if (node.id) return node.id;
}
return null;
}
export function InventoryPage() {
const [nodes, setNodes] = useState<InventoryTreeNode[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [context, setContext] = useState<NodeContext | null>(null);
const [loadingTree, setLoadingTree] = useState(true);
const [loadingContext, setLoadingContext] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadContext = async (id: string) => {
setSelectedId(id);
setLoadingContext(true);
try {
const item = await fetchNodeContext(id);
setContext(item);
} catch (err) {
setError(err instanceof Error ? err.message : 'Errore sconosciuto');
} finally {
setLoadingContext(false);
}
};
useEffect(() => {
let active = true;
setLoadingTree(true);
fetchInventoryTree()
.then((items) => {
if (!active) return;
setNodes(items);
const first = findFirstNode(items);
if (first) loadContext(first);
})
.catch((err: Error) => {
if (!active) return;
setError(err.message);
})
.finally(() => {
if (active) setLoadingTree(false);
});
return () => {
active = false;
};
}, []);
if (loadingTree) {
return <div className="inventory-empty">Caricamento inventory...</div>;
}
if (error) {
return <div className="inventory-error">Errore inventory: {error}</div>;
}
return (
<div className="inventory-layout">
<InventoryTree nodes={nodes} selectedId={selectedId} onSelect={loadContext} />
<NodeContextPanel context={context} loading={loadingContext} onNavigateNode={loadContext} />
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import { AppLayout } from '../components/AppLayout';
import { fetchObjects } from '../lib/api';
import type { NetObject, ViewKey } from '../types';
export function InventoryView({ onNavigate }: { onNavigate: (v: ViewKey) => void }) {
const [objects, setObjects] = useState<NetObject[]>([]);
useEffect(() => {
fetchObjects().then(setObjects);
}, []);
return (
<AppLayout current="inventory" onNavigate={onNavigate}>
<header className="header">
<h1 className="page-title">Inventory</h1>
</header>
<div className="card">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Vendor</th>
<th>Model</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{objects.length === 0 && (
<tr>
<td colSpan={5} className="empty">Nessun oggetto</td>
</tr>
)}
{objects.map((o: NetObject) => (
<tr key={o.id}>
<td>{o.name}</td>
<td>{o.type}</td>
<td>{o.vendor ?? '-'}</td>
<td>{o.model ?? '-'}</td>
<td>{o.status ?? '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</AppLayout>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { setToken, setSessionUser } from '../lib/session';
type Props = { onLogin: () => void };
export function LoginPage({ onLogin }: Props) {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('admin');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const body = new URLSearchParams();
body.set('username', username);
body.set('password', password);
const res = await fetch('/backend/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.detail ?? 'Login fallito');
}
const { access_token } = await res.json();
setToken(access_token);
const meRes = await fetch('/backend/auth/me', {
headers: { Authorization: `Bearer ${access_token}` },
});
if (meRes.ok) setSessionUser(await meRes.json());
onLogin();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login fallito');
} finally {
setLoading(false);
}
};
return (
<main className="login-page">
<section className="login-card">
<p className="login-eyebrow">NetMapper auth</p>
<h1>Accedi al book of truth</h1>
<p className="login-copy">L'accesso è protetto per impedire lettura e modifica dei dati da terzi.</p>
<form onSubmit={submit} className="login-form">
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<label>
Password
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</label>
{error && <p className="login-error">{error}</p>}
<button type="submit" disabled={loading}>{loading ? 'Accesso...' : 'Entra'}</button>
</form>
</section>
</main>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { useCallback, useEffect, useState } from 'react';
import { AppLayout } from '../components/AppLayout';
import { fetchServices, serviceAction } from '../lib/api';
import type { ServiceInfo, ViewKey } from '../types';
function stateClass(state: string) {
switch (state) {
case 'active':
return 'status status-active';
case 'failed':
return 'status status-failed';
case 'activating':
case 'deactivating':
return 'status status-pending';
default:
return 'status status-inactive';
}
}
export function ServicesView({ onNavigate }: { onNavigate: (v: ViewKey) => void }) {
const [services, setServices] = useState<ServiceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setError(null);
const data = await fetchServices();
setServices(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Errore sconosciuto');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const timer = setInterval(load, 5000);
return () => clearInterval(timer);
}, [load]);
const runAction = async (name: string, action: 'start' | 'stop' | 'restart') => {
try {
setBusy(`${name}:${action}`);
await serviceAction(name, action);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : 'Errore sconosciuto');
} finally {
setBusy(null);
}
};
return (
<AppLayout current="services" onNavigate={onNavigate}>
<header className="header">
<h1 className="page-title">Services</h1>
<button className="btn" onClick={load}>Refresh</button>
</header>
{error && <div className="alert">{error}</div>}
<div className="card">
{loading ? (
<p className="empty">Caricamento stato servizi...</p>
) : (
<table className="table">
<thead>
<tr>
<th>Service</th>
<th>State</th>
<th>Substate</th>
<th>Loaded</th>
<th>PID</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{services.length === 0 && (
<tr>
<td colSpan={6} className="empty">Nessun servizio disponibile</td>
</tr>
)}
{services.map(service => (
<tr key={service.name}>
<td>{service.name}</td>
<td><span className={stateClass(service.active_state)}>{service.active_state}</span></td>
<td>{service.sub_state}</td>
<td>{service.loaded}</td>
<td>{service.pid ?? '-'}</td>
<td>
<div className="actions">
<button className="btn" disabled={!!busy} onClick={() => runAction(service.name, 'start')}>Start</button>
<button className="btn" disabled={!!busy} onClick={() => runAction(service.name, 'stop')}>Stop</button>
<button className="btn btn-primary" disabled={!!busy} onClick={() => runAction(service.name, 'restart')}>Restart</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</AppLayout>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
import { AppLayout } from '../components/AppLayout';
import type { ViewKey } from '../types';
import { fetchObjects } from '../lib/api';
export function TopologyView({ onNavigate }: { onNavigate: (v: ViewKey) => void }) {
const [objects, setObjects] = useState<Awaited<ReturnType<typeof fetchObjects>>>([]);
useEffect(() => {
fetchObjects().then(setObjects);
}, []);
return (
<AppLayout current="topology" onNavigate={onNavigate}>
<header className="header">
<h1 className="page-title">Topology</h1>
</header>
<div className="card">
<p className="empty">Vista topologia: prossima implementazione (grafo/annidamento).</p>
<div style={{ marginTop: '1rem' }}>
<strong>Oggetti mockati:</strong>
<pre style={{ marginTop: '0.5rem', fontSize: '0.85rem' }}>
{JSON.stringify(objects, null, 2)}
</pre>
</div>
</div>
</AppLayout>
);
}
+37
View File
@@ -0,0 +1,37 @@
export type ObjectType =
| 'site'
| 'area'
| 'rack'
| 'physical_host'
| 'vm'
| 'lxc'
| 'app'
| 'switch'
| 'firewall'
| 'router'
| 'ap'
| 'camera'
| 'storage'
| 'other';
export interface NetObject {
id: string;
name: string;
slug: string;
type: ObjectType;
status?: string;
vendor?: string;
model?: string;
serial?: string;
notes?: string;
}
export type ViewKey = 'topology' | 'inventory' | 'ips' | 'discovery' | 'services';
export interface ServiceInfo {
name: string;
active_state: string;
sub_state: string;
loaded: string;
pid?: number | null;
}
+89
View File
@@ -0,0 +1,89 @@
export type ObjectType =
| 'site'
| 'area'
| 'rack'
| 'physical_host'
| 'vm'
| 'lxc'
| 'container'
| 'app'
| 'switch'
| 'firewall'
| 'router'
| 'ap'
| 'camera'
| 'storage'
| 'other';
export type ObjectStatus = 'active' | 'planned' | 'inactive' | 'retired' | 'unknown';
export type RelationType =
| 'contains'
| 'mounted_in'
| 'attached_to'
| 'connects_to'
| 'uplink_to'
| 'routes_via'
| 'bridges_to'
| 'depends_on'
| 'hosts_service'
| 'managed_by';
export type RelationLayer = 'physical' | 'network' | 'logical' | 'service';
export interface InventoryTreeNode {
id: string;
name: string;
type: ObjectType;
status: ObjectStatus;
badges: string[];
children_count: number;
children: InventoryTreeNode[];
}
export interface NetObject {
id: string;
name: string;
slug?: string | null;
type: ObjectType;
parent_id?: string | null;
status: ObjectStatus;
vendor?: string | null;
model?: string | null;
serial?: string | null;
notes?: string | null;
created_at: string;
updated_at: string;
}
export interface Relation {
id: string;
type: RelationType;
layer: RelationLayer;
source_id: string;
target_id: string;
direction: 'forward';
source: 'manual' | 'snmp' | 'lldp' | 'arp' | 'nmap' | 'inference' | 'import';
confidence: number;
status: 'active' | 'stale' | 'planned' | 'deleted';
attributes: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface NodeContext {
item: NetObject;
parent: NetObject | null;
children: NetObject[];
relations: {
incoming: Relation[];
outgoing: Relation[];
};
}
export interface ListResponse<T> {
count: number;
items: T[];
}
export interface EntityResponse<T> {
item: T;
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
// All backend API calls go through /backend -> strips prefix
'/backend': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/backend/, ''),
},
// Local agent (unauthenticated)
'/api': {
target: 'http://127.0.0.1:8001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});
+5
View File
@@ -0,0 +1,5 @@
def main():
print("NetMapper worker bootstrap")
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
redis==5.1.1
pydantic==2.9.2
python-nmap==0.7.1
pysnmp==7.1.15