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>
This commit is contained in:
160
backend/app/api/vessels.py
Normal file
160
backend/app/api/vessels.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user