import logging from typing import Any, Dict, List from fastapi import HTTPException from sqlmodel import Session, select from core.cache import cache from core.docker_instance import docker_manager from models.bot import BotInstance, NanobotImage from services.cache_service import _cache_key_images, _invalidate_images_cache logger = logging.getLogger("dashboard.backend") def _serialize_image(row: NanobotImage) -> Dict[str, Any]: created_at = row.created_at.isoformat() + "Z" if row.created_at else None return { "tag": row.tag, "image_id": row.image_id, "version": row.version, "status": row.status, "source_dir": row.source_dir, "created_at": created_at, } def _reconcile_registered_images(session: Session) -> None: rows = session.exec(select(NanobotImage)).all() dirty = False for row in rows: docker_exists = docker_manager.has_image(row.tag) next_status = "READY" if docker_exists else "ERROR" next_image_id = row.image_id if docker_exists and docker_manager.client: try: next_image_id = docker_manager.client.images.get(row.tag).id except Exception: next_image_id = row.image_id if row.status != next_status or row.image_id != next_image_id: row.status = next_status row.image_id = next_image_id session.add(row) dirty = True if dirty: session.commit() def list_registered_images(session: Session) -> List[Dict[str, Any]]: cached = cache.get_json(_cache_key_images()) if isinstance(cached, list) and all(isinstance(row, dict) for row in cached): return cached if isinstance(cached, list): _invalidate_images_cache() try: _reconcile_registered_images(session) except Exception as exc: logger.warning("image reconcile skipped: %s", exc) rows = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all() payload = [_serialize_image(row) for row in rows] cache.set_json(_cache_key_images(), payload, ttl=60) return payload def delete_registered_image(session: Session, *, tag: str) -> Dict[str, Any]: image = session.get(NanobotImage, tag) if not image: raise HTTPException(status_code=404, detail="Image not found") bots_using = session.exec(select(BotInstance).where(BotInstance.image_tag == tag)).all() if bots_using: raise HTTPException(status_code=400, detail=f"Cannot delete image: {len(bots_using)} bots are using it.") session.delete(image) session.commit() _invalidate_images_cache() return {"status": "deleted"} def list_docker_images_by_repository(repository: str = "nanobot-base") -> List[Dict[str, Any]]: return docker_manager.list_images_by_repo(repository) def register_image(session: Session, payload: Dict[str, Any]) -> Dict[str, Any]: tag = str(payload.get("tag") or "").strip() source_dir = str(payload.get("source_dir") or "manual").strip() or "manual" if not tag: raise HTTPException(status_code=400, detail="tag is required") if not docker_manager.has_image(tag): raise HTTPException(status_code=404, detail=f"Docker image not found: {tag}") version = tag.split(":")[-1].removeprefix("v") if ":" in tag else tag try: docker_img = docker_manager.client.images.get(tag) if docker_manager.client else None image_id = docker_img.id if docker_img else None except Exception: image_id = None row = session.get(NanobotImage, tag) if not row: row = NanobotImage( tag=tag, version=version, status="READY", source_dir=source_dir, image_id=image_id, ) else: row.version = version row.status = "READY" row.source_dir = source_dir row.image_id = image_id session.add(row) session.commit() session.refresh(row) _invalidate_images_cache() return _serialize_image(row)