Compare commits
55 Commits
13216f7956
..
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 |
@@ -33,3 +33,7 @@ NetMapper è una web app self-hosted per la mappatura dinamica di un homelab su
|
|||||||
- Database: PostgreSQL
|
- Database: PostgreSQL
|
||||||
- Cache/queue: Redis
|
- Cache/queue: Redis
|
||||||
- Deploy: Docker Compose
|
- Deploy: Docker Compose
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
|
- `agent/` - control agent per gestire i servizi systemd (start/stop/status).
|
||||||
|
|||||||
+72
@@ -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
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -13,8 +13,10 @@
|
|||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"vite": "^5.4.8",
|
"vite": "^5.4.8"
|
||||||
"@vitejs/plugin-react": "^4.3.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'; }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { App } from './pages/App'
|
import App from './App'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -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,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