Files
Agentic-OS/backend/app/api/vessels.py
nearxos 6185b9b85a Initial commit: Agentic OS troubleshooting platform
Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend +
LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini ->
DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials,
RBAC, audit log, project-memory + Obsidian integrations, and editable
troubleshooting decision rules tuned for the GeneseasX vessel stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 22:11:07 +03:00

161 lines
5.6 KiB
Python

"""Vessel inventory CRUD with onboard device checklist."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.deps import require_readonly, require_support
from app.core.crypto import decrypt_secret
from app.database import get_db
from app.inventory_catalog import catalog_as_dicts
from app.models.inventory import Device, Vessel
from app.models.user import User
from app.schemas import CatalogEntryOut, DeviceOut, VesselCreate, VesselDetail, VesselOut, VesselUpdate
from app.services.vessel_devices import build_device, device_to_out, validate_selections
router = APIRouter(prefix="/api/vessels", tags=["vessels"])
@router.get("/catalog", response_model=list[CatalogEntryOut])
async def list_device_catalog(_: User = Depends(require_readonly)):
"""Predefined device/VM slots with default ports for vessel setup."""
return catalog_as_dicts()
@router.get("", response_model=list[VesselDetail])
async def list_vessels(db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)):
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).order_by(Vessel.name)
)
vessels = res.scalars().all()
out: list[VesselDetail] = []
for vessel in vessels:
detail = VesselDetail.model_validate(vessel)
detail.devices = [device_to_out(d) for d in vessel.devices]
out.append(detail)
return out
@router.get("/{vessel_id}", response_model=VesselDetail)
async def get_vessel(
vessel_id: int, db: AsyncSession = Depends(get_db), _: User = Depends(require_readonly)
):
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel_id)
)
vessel = res.scalar_one_or_none()
if not vessel:
raise HTTPException(status_code=404, detail="Vessel not found")
detail = VesselDetail.model_validate(vessel)
detail.devices = [device_to_out(d) for d in vessel.devices]
return detail
@router.post("", response_model=VesselDetail, status_code=status.HTTP_201_CREATED)
async def create_vessel(
body: VesselCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_support),
):
try:
pairs = validate_selections(body.devices)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not body.public_ip:
needs_ip = any(not s.address for s, _ in pairs)
if needs_ip:
raise HTTPException(
status_code=400,
detail="Vessel public IP is required when devices use the shared endpoint",
)
vessel = Vessel(
name=body.name,
site=body.site,
public_ip=body.public_ip,
notes=body.notes,
)
db.add(vessel)
await db.flush()
for sel, entry in pairs:
db.add(build_device(vessel, sel, entry))
await db.commit()
await db.refresh(vessel)
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel.id)
)
saved = res.scalar_one()
detail = VesselDetail.model_validate(saved)
detail.devices = [device_to_out(d) for d in saved.devices]
return detail
@router.patch("/{vessel_id}", response_model=VesselDetail)
async def update_vessel(
vessel_id: int,
body: VesselUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_support),
):
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel_id)
)
vessel = res.scalar_one_or_none()
if not vessel:
raise HTTPException(status_code=404, detail="Vessel not found")
for field in ("name", "site", "public_ip", "notes"):
val = getattr(body, field)
if val is not None:
setattr(vessel, field, val)
if body.devices is not None:
try:
pairs = validate_selections(body.devices)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not vessel.public_ip and any(not s.address for s, _ in pairs):
raise HTTPException(status_code=400, detail="Set vessel public IP or per-device addresses")
# Replace onboard devices from the new checklist
old_by_key = {d.catalog_key: d for d in vessel.devices if d.catalog_key}
for d in list(vessel.devices):
await db.delete(d)
await db.flush()
for sel, entry in pairs:
existing_plain = None
if not sel.secret and sel.catalog_key in old_by_key:
enc = old_by_key[sel.catalog_key].secret_enc
if enc:
existing_plain = decrypt_secret(enc)
db.add(build_device(vessel, sel, entry, existing_secret_plain=existing_plain))
await db.commit()
res = await db.execute(
select(Vessel).options(selectinload(Vessel.devices)).where(Vessel.id == vessel.id)
)
saved = res.scalar_one()
detail = VesselDetail.model_validate(saved)
detail.devices = [device_to_out(d) for d in saved.devices]
return detail
@router.delete("/{vessel_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_vessel(
vessel_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_support),
):
res = await db.execute(select(Vessel).where(Vessel.id == vessel_id))
vessel = res.scalar_one_or_none()
if not vessel:
raise HTTPException(status_code=404, detail="Vessel not found")
await db.delete(vessel)
await db.commit()