feat(network): thumbnail generation on save, shown on list page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-14 01:22:51 +00:00
parent 7efa22454d
commit c8f571db39
6 changed files with 84 additions and 2 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -51,6 +51,10 @@ export const networkDiagramsApi = {
return response.data
},
async uploadThumbnail(id: string, dataUrl: string): Promise<void> {
await apiClient.post(`/network-diagrams/${id}/thumbnail`, { data_url: dataUrl })
},
async aiGenerate(data: AIGenerateRequest): Promise<AIGenerateResponse> {
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
return response.data

View File

@@ -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(() => {

View File

@@ -260,6 +260,21 @@ export default function NetworkDiagramsPage() {
{d.description && (
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
)}
{/* Thumbnail preview */}
{d.thumbnail_url ? (
<div className="mb-2 overflow-hidden rounded border border-default">
<img
src={d.thumbnail_url}
alt={d.name}
className="h-[120px] w-full object-cover"
onError={e => { (e.target as HTMLImageElement).style.display = 'none' }}
/>
</div>
) : (
<div className="mb-2 flex h-[120px] items-center justify-center rounded border border-default bg-elevated">
<Network size={32} className="text-muted-foreground/30" />
</div>
)}
{d.node_count > 0 && (
<div className="mb-2">
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />

View File

@@ -73,6 +73,7 @@ export interface NetworkDiagramListItem {
description: string | null
node_count: number
category_counts: Record<string, number>
thumbnail_url?: string | null
created_by: string | null
created_at: string
updated_at: string