Compare commits
57 Commits
6c2fee5aed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d9468eedf | |||
| 1e0956dcc8 | |||
| 6927c3ea32 | |||
| 472cc601a6 | |||
| 6594d27670 | |||
| fd669b81df | |||
| d651fc3ff2 | |||
| 133f059f13 | |||
| 60d8835e99 | |||
| 75dd560baf | |||
| d61a9ee17a | |||
| 437dbfe148 | |||
| 4c983d4ab6 | |||
| 1c3cb28af1 | |||
| 87757ac1ea | |||
| 54976814fc | |||
| 76886b93d3 | |||
| f47443ad98 | |||
| 45fb390cb7 | |||
| fd93758567 | |||
| a732ac5655 | |||
| 8c0d3d6a26 | |||
| 70dd820e88 | |||
| 5c09359ee8 | |||
| 143baddf0a | |||
| 9e5cd580db | |||
| 0822cb1a60 | |||
| 9ca17c7820 | |||
| 6d635109d3 | |||
| f8e191a5d5 | |||
| 092bda19eb | |||
| 15b18d3421 | |||
| fa6561de58 | |||
| a6d0dd6c68 | |||
| 8aae681706 | |||
| 998c5246dd | |||
| 12a6dffcb9 | |||
| 95a6992f62 | |||
| 0dffde8f87 | |||
| d2fd4d1078 | |||
| 92b0136015 | |||
| 3567c17bb1 | |||
| d16dcfe66e | |||
| eb0e7b8f97 | |||
| d29a05fff6 | |||
| 49def19561 | |||
| 8b2ed677a7 | |||
| 4466e59684 | |||
| 69124b31f1 | |||
| dc1b2ab99b | |||
| 186aae1b5f | |||
| be55c35c3a | |||
| 871f4d0547 | |||
| 00ecb1862d | |||
| 0294301877 | |||
| 13216f7956 | |||
| 96130a11f4 |
+11
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
coverage/
|
||||
.pytest_cache/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,39 @@
|
||||
# NetMapper2026
|
||||
|
||||
NetMapper è una web app self-hosted per la mappatura dinamica di un homelab su più livelli: fisico, logico, gerarchico e inventario/IP.
|
||||
|
||||
## Obiettivi iniziali
|
||||
|
||||
- Discovery di rete multi-livello con granularitਨ.
|
||||
- Modellazione gerarchica di host fisici, VM, container, app e dispositivi di rete.
|
||||
- Associazione IP/MAC/interfacce agli oggetti mappati.
|
||||
- Vista fisica e logica editabile, con arricchimento manuale.
|
||||
- Deploy semplice in LXC Proxmox con base portabile verso Docker/Podman.
|
||||
|
||||
## Monorepo layout
|
||||
|
||||
- `docs/` - visione, modello dati, roadmap e scelte architetturali.
|
||||
- `frontend/` - UI web dell'applicazione.
|
||||
- `backend/` - API, logica dominio e persistenza.
|
||||
- `worker/` - motore di discovery e job asincroni.
|
||||
- `deploy/` - file di deploy, compose e containerizzazione.
|
||||
|
||||
## MVP
|
||||
|
||||
1. CRUD oggetti, IP, interfacce e relazioni.
|
||||
2. Discovery base subnet/host/porte.
|
||||
3. Discovery SNMP/LLDP per topologia iniziale.
|
||||
4. Mappa visuale con nodi annidati e pannello dettagli.
|
||||
|
||||
## Stack proposto
|
||||
|
||||
- Frontend: React + TypeScript + Vite
|
||||
- Backend: FastAPI
|
||||
- Worker discovery: Python
|
||||
- Database: PostgreSQL
|
||||
- Cache/queue: Redis
|
||||
- Deploy: Docker Compose
|
||||
|
||||
### Agent
|
||||
|
||||
- `agent/` - control agent per gestire i servizi systemd (start/stop/status).
|
||||
+72
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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')
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
dbus-python==1.3.2
|
||||
pydantic==2.9.2
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
python-jose[cryptography]
|
||||
passlib[bcrypt]
|
||||
python-multipart
|
||||
sqlalchemy
|
||||
bcrypt==4.0.1
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,89 @@
|
||||
# Domain Model
|
||||
|
||||
## Entitਨ principali
|
||||
|
||||
### Object
|
||||
Rappresenta qualsiasi entitਨ mappabile.
|
||||
|
||||
Campi iniziali:
|
||||
- `id`
|
||||
- `name`
|
||||
- `slug`
|
||||
- `type` (`site`, `area`, `rack`, `physical_host`, `vm`, `lxc`, `app`, `switch`, `firewall`, `router`, `ap`, `camera`, `storage`, `other`)
|
||||
- `status`
|
||||
- `vendor`
|
||||
- `model`
|
||||
- `serial`
|
||||
- `os_name`
|
||||
- `os_version`
|
||||
- `notes`
|
||||
|
||||
### ObjectRelation
|
||||
Relazione tra due oggetti.
|
||||
|
||||
Campi iniziali:
|
||||
- `id`
|
||||
- `source_object_id`
|
||||
- `target_object_id`
|
||||
- `relation_type` (`contains`, `hosts`, `connected_to`, `depends_on`, `managed_by`, `runs`)
|
||||
- `confidence`
|
||||
- `source_type` (`manual`, `nmap`, `snmp`, `lldp`, `arp`, `api`)
|
||||
|
||||
### NetworkInterface
|
||||
Interfaccia di rete collegata a un oggetto.
|
||||
|
||||
Campi iniziali:
|
||||
- `id`
|
||||
- `object_id`
|
||||
- `name`
|
||||
- `mac_address`
|
||||
- `mtu`
|
||||
- `speed`
|
||||
- `admin_state`
|
||||
- `oper_state`
|
||||
- `description`
|
||||
|
||||
### IPAddress
|
||||
Indirizzo IP associato a un oggetto o a un'interfaccia.
|
||||
|
||||
Campi iniziali:
|
||||
- `id`
|
||||
- `address`
|
||||
- `version`
|
||||
- `prefix_length`
|
||||
- `hostname`
|
||||
- `dns_name`
|
||||
- `status`
|
||||
- `object_id`
|
||||
- `interface_id`
|
||||
- `is_primary`
|
||||
- `source_type`
|
||||
|
||||
### DiscoveryJob
|
||||
Esecuzione di un task di discovery.
|
||||
|
||||
Campi iniziali:
|
||||
- `id`
|
||||
- `job_type`
|
||||
- `scope`
|
||||
- `status`
|
||||
- `started_at`
|
||||
- `finished_at`
|
||||
- `requested_by`
|
||||
- `parameters_json`
|
||||
- `result_summary`
|
||||
|
||||
### Evidence
|
||||
Dato grezzo o semi-normalizzato raccolto da una sorgente.
|
||||
|
||||
Campi iniziali:
|
||||
- `id`
|
||||
- `source_type`
|
||||
- `object_hint`
|
||||
- `payload_json`
|
||||
- `observed_at`
|
||||
- `fingerprint`
|
||||
|
||||
## Nota di progettazione
|
||||
|
||||
Separare oggetti, relazioni e evidenze consente di distinguere ciò che il sistema ha osservato da ciò che il sistema ha consolidato nel modello finale.
|
||||
@@ -0,0 +1,31 @@
|
||||
# MVP Roadmap
|
||||
|
||||
## Fase 0 - Fondazioni
|
||||
- Inizializzare monorepo.
|
||||
- Definire modello dati iniziale.
|
||||
- Preparare stack locale con Postgres e Redis.
|
||||
|
||||
## Fase 1 - Inventario manuale
|
||||
- CRUD oggetti.
|
||||
- CRUD IP e interfacce.
|
||||
- Relazioni padre/figlio e connected_to.
|
||||
|
||||
## Fase 2 - Discovery base
|
||||
- Scan subnet configurate.
|
||||
- Host attivi, porte base, reverse DNS.
|
||||
- Prima normalizzazione in inventario.
|
||||
|
||||
## Fase 3 - Discovery arricchita
|
||||
- SNMP read-only.
|
||||
- Neighbor LLDP/CDP.
|
||||
- MAC table e inferenza link.
|
||||
|
||||
## Fase 4 - UI mappa
|
||||
- Vista grafo iniziale.
|
||||
- Contenitori annidati.
|
||||
- Pannello dettagli per oggetto/IP.
|
||||
|
||||
## Fase 5 - Connettori homelab
|
||||
- Proxmox.
|
||||
- Docker/Podman.
|
||||
- Eventuali import futuri da altri sistemi.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Vision
|
||||
|
||||
NetMapper nasce per diventare il punto centrale di osservazione e documentazione di un homelab.
|
||||
|
||||
L'applicazione deve unire discovery automatico e modellazione manuale, perché la topologia reale non è sempre deducibile in modo completo dal solo scanning di rete. Il risultato atteso non è solo un inventario di IP, ma una mappa navigabile che rappresenti relazioni, contenimento, collegamenti e contesto operativo.
|
||||
|
||||
## Viste principali
|
||||
|
||||
- Vista fisica: switch, firewall, server, access point, modem, telecamere e collegamenti.
|
||||
- Vista logica: subnet, VLAN, servizi esposti, flussi e dipendenze.
|
||||
- Vista gerarchica: host fisico > VM/LXC > app/servizi.
|
||||
- Vista inventario/IP: indirizzi, MAC, interfacce, metadati, note e sorgenti discovery.
|
||||
|
||||
## Principi
|
||||
|
||||
- Source of truth interna, ma alimentata da discovery.
|
||||
- Ogni informazione deve avere origine, timestamp e livello di confidenza.
|
||||
- Nessun lock-in sul runtime: deve girare bene sia in LXC sia in container.
|
||||
- Plugin/connettori per estendere discovery e import dati.
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetMapper</title>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "netmapper-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & 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 /></>;
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'; }
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
export function App() {
|
||||
return (
|
||||
<main style={{ fontFamily: 'sans-serif', padding: '2rem' }}>
|
||||
<h1>NetMapper</h1>
|
||||
<p>Bootstrap iniziale della UI.</p>
|
||||
<ul>
|
||||
<li>Topology view</li>
|
||||
<li>Inventory view</li>
|
||||
<li>IP registry</li>
|
||||
<li>Discovery jobs</li>
|
||||
</ul>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -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/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
def main():
|
||||
print("NetMapper worker bootstrap")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
redis==5.1.1
|
||||
pydantic==2.9.2
|
||||
python-nmap==0.7.1
|
||||
pysnmp==7.1.15
|
||||
Reference in New Issue
Block a user