From c8f571db39d8efde80d3e95f927ada45c9c9ae5d Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 14 Apr 2026 01:22:51 +0000 Subject: [PATCH] feat(network): thumbnail generation on save, shown on list page Co-Authored-By: Claude Sonnet 4.6 --- backend/app/api/endpoints/network_diagrams.py | 33 ++++++++++++++++++- backend/app/schemas/network_diagram.py | 1 + frontend/src/api/networkDiagrams.ts | 4 +++ .../pages/NetworkDiagrams/DiagramEditor.tsx | 32 +++++++++++++++++- frontend/src/pages/NetworkDiagrams/index.tsx | 15 +++++++++ frontend/src/types/network-diagram.ts | 1 + 6 files changed, 84 insertions(+), 2 deletions(-) diff --git a/backend/app/api/endpoints/network_diagrams.py b/backend/app/api/endpoints/network_diagrams.py index e00ecf7a..d2538aa1 100644 --- a/backend/app/api/endpoints/network_diagrams.py +++ b/backend/app/api/endpoints/network_diagrams.py @@ -1,10 +1,12 @@ """Network diagrams API endpoints.""" +import base64 import logging from datetime import datetime, timezone from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel from sqlalchemy import select, or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -27,7 +29,7 @@ from app.schemas.network_diagram import ( DiagramNode, DiagramEdge, ) -from app.services import network_diagram_ai_service +from app.services import network_diagram_ai_service, storage_service # Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts _SLUG_CATEGORY: dict[str, str] = { @@ -83,6 +85,7 @@ def _diagram_to_list_item( description=diagram.description, node_count=len(nodes), category_counts=category_counts, + thumbnail_url=diagram.thumbnail_url, created_by=diagram.created_by, created_at=diagram.created_at, updated_at=diagram.updated_at, @@ -305,6 +308,34 @@ async def import_diagram( ) +class ThumbnailUploadRequest(BaseModel): + data_url: str # base64 PNG data URL: "data:image/png;base64,..." + + +@router.post("/{diagram_id}/thumbnail", status_code=204) +async def upload_thumbnail( + diagram_id: UUID, + body: ThumbnailUploadRequest, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_active_user)], +) -> None: + diagram = await _get_diagram_or_404(diagram_id, current_user.account_id, db) + try: + header, encoded = body.data_url.split(",", 1) + except ValueError: + raise HTTPException(status_code=422, detail="Invalid data URL format") + image_bytes = base64.b64decode(encoded) + storage_key = await storage_service.upload_file( + file_data=image_bytes, + filename=f"thumbnail-{diagram_id}.png", + content_type="image/png", + account_id=str(current_user.account_id), + ) + presigned_url = storage_service.get_presigned_url(storage_key) + diagram.thumbnail_url = presigned_url + await db.commit() + + @router.post("/ai-generate", response_model=AIGenerateResponse) async def ai_generate_diagram( data: AIGenerateRequest, diff --git a/backend/app/schemas/network_diagram.py b/backend/app/schemas/network_diagram.py index 8b85a6f2..55ca149b 100644 --- a/backend/app/schemas/network_diagram.py +++ b/backend/app/schemas/network_diagram.py @@ -92,6 +92,7 @@ class NetworkDiagramListItem(BaseModel): description: str | None = None node_count: int = 0 category_counts: dict[str, int] = Field(default_factory=dict) + thumbnail_url: str | None = None created_by: UUID | None = None created_at: datetime updated_at: datetime diff --git a/frontend/src/api/networkDiagrams.ts b/frontend/src/api/networkDiagrams.ts index c074fb00..245b4417 100644 --- a/frontend/src/api/networkDiagrams.ts +++ b/frontend/src/api/networkDiagrams.ts @@ -51,6 +51,10 @@ export const networkDiagramsApi = { return response.data }, + async uploadThumbnail(id: string, dataUrl: string): Promise { + await apiClient.post(`/network-diagrams/${id}/thumbnail`, { data_url: dataUrl }) + }, + async aiGenerate(data: AIGenerateRequest): Promise { const response = await apiClient.post('/network-diagrams/ai-generate', data) return response.data diff --git a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx index efad2e94..0d000f98 100644 --- a/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx +++ b/frontend/src/pages/NetworkDiagrams/DiagramEditor.tsx @@ -334,21 +334,51 @@ function DiagramEditorInner() { nodes: serializeNodes(), edges: serializeEdges(), } + let savedId: string | null = diagramIdRef.current if (diagramIdRef.current) { await networkDiagramsApi.update(diagramIdRef.current, payload) } else { const created = await networkDiagramsApi.create(payload) + savedId = created.id setDiagramId(created.id) navigate(`/network-diagrams/${created.id}`, { replace: true }) } setIsDirty(false) setLastSavedAt(new Date()) + + // Generate thumbnail in the background — don't block save UX on failure + if (savedId && nodes.length > 0) { + try { + const { toPng } = await import('html-to-image') + const THUMB_W = 480 + const THUMB_H = 300 + const bounds = getNodesBounds(nodes) + const viewport = getViewportForBounds(bounds, THUMB_W, THUMB_H, 0.5, 2, 0.1) + const flowEl = document.querySelector('.react-flow__viewport') as HTMLElement | null + if (flowEl) { + const dataUrl = await toPng(flowEl, { + backgroundColor: '#16181f', + width: THUMB_W, + height: THUMB_H, + style: { + width: `${THUMB_W}px`, + height: `${THUMB_H}px`, + transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, + transformOrigin: 'top left', + }, + }) + await networkDiagramsApi.uploadThumbnail(savedId, dataUrl) + } + } catch { + // Thumbnail failure is silent — doesn't affect save success + } + } } catch { toast.error('Failed to save diagram') } finally { setIsSaving(false) } - }, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate]) + }, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate, nodes]) useEffect(() => { const interval = setInterval(() => { diff --git a/frontend/src/pages/NetworkDiagrams/index.tsx b/frontend/src/pages/NetworkDiagrams/index.tsx index 11a71128..c62483f6 100644 --- a/frontend/src/pages/NetworkDiagrams/index.tsx +++ b/frontend/src/pages/NetworkDiagrams/index.tsx @@ -260,6 +260,21 @@ export default function NetworkDiagramsPage() { {d.description && (

{d.description}

)} + {/* Thumbnail preview */} + {d.thumbnail_url ? ( +
+ {d.name} { (e.target as HTMLImageElement).style.display = 'none' }} + /> +
+ ) : ( +
+ +
+ )} {d.node_count > 0 && (
diff --git a/frontend/src/types/network-diagram.ts b/frontend/src/types/network-diagram.ts index 0ce62a3f..600006f7 100644 --- a/frontend/src/types/network-diagram.ts +++ b/frontend/src/types/network-diagram.ts @@ -73,6 +73,7 @@ export interface NetworkDiagramListItem { description: string | null node_count: number category_counts: Record + thumbnail_url?: string | null created_by: string | null created_at: string updated_at: string