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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user