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."""
|
"""Network diagrams API endpoints."""
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, or_
|
from sqlalchemy import select, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ from app.schemas.network_diagram import (
|
|||||||
DiagramNode,
|
DiagramNode,
|
||||||
DiagramEdge,
|
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
|
# Maps system device-type slugs to their category — mirrors frontend deviceRegistry.ts
|
||||||
_SLUG_CATEGORY: dict[str, str] = {
|
_SLUG_CATEGORY: dict[str, str] = {
|
||||||
@@ -83,6 +85,7 @@ def _diagram_to_list_item(
|
|||||||
description=diagram.description,
|
description=diagram.description,
|
||||||
node_count=len(nodes),
|
node_count=len(nodes),
|
||||||
category_counts=category_counts,
|
category_counts=category_counts,
|
||||||
|
thumbnail_url=diagram.thumbnail_url,
|
||||||
created_by=diagram.created_by,
|
created_by=diagram.created_by,
|
||||||
created_at=diagram.created_at,
|
created_at=diagram.created_at,
|
||||||
updated_at=diagram.updated_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)
|
@router.post("/ai-generate", response_model=AIGenerateResponse)
|
||||||
async def ai_generate_diagram(
|
async def ai_generate_diagram(
|
||||||
data: AIGenerateRequest,
|
data: AIGenerateRequest,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class NetworkDiagramListItem(BaseModel):
|
|||||||
description: str | None = None
|
description: str | None = None
|
||||||
node_count: int = 0
|
node_count: int = 0
|
||||||
category_counts: dict[str, int] = Field(default_factory=dict)
|
category_counts: dict[str, int] = Field(default_factory=dict)
|
||||||
|
thumbnail_url: str | None = None
|
||||||
created_by: UUID | None = None
|
created_by: UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export const networkDiagramsApi = {
|
|||||||
return response.data
|
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> {
|
async aiGenerate(data: AIGenerateRequest): Promise<AIGenerateResponse> {
|
||||||
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
|
const response = await apiClient.post<AIGenerateResponse>('/network-diagrams/ai-generate', data)
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -334,21 +334,51 @@ function DiagramEditorInner() {
|
|||||||
nodes: serializeNodes(),
|
nodes: serializeNodes(),
|
||||||
edges: serializeEdges(),
|
edges: serializeEdges(),
|
||||||
}
|
}
|
||||||
|
let savedId: string | null = diagramIdRef.current
|
||||||
if (diagramIdRef.current) {
|
if (diagramIdRef.current) {
|
||||||
await networkDiagramsApi.update(diagramIdRef.current, payload)
|
await networkDiagramsApi.update(diagramIdRef.current, payload)
|
||||||
} else {
|
} else {
|
||||||
const created = await networkDiagramsApi.create(payload)
|
const created = await networkDiagramsApi.create(payload)
|
||||||
|
savedId = created.id
|
||||||
setDiagramId(created.id)
|
setDiagramId(created.id)
|
||||||
navigate(`/network-diagrams/${created.id}`, { replace: true })
|
navigate(`/network-diagrams/${created.id}`, { replace: true })
|
||||||
}
|
}
|
||||||
setIsDirty(false)
|
setIsDirty(false)
|
||||||
setLastSavedAt(new Date())
|
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 {
|
} catch {
|
||||||
toast.error('Failed to save diagram')
|
toast.error('Failed to save diagram')
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate])
|
}, [name, clientName, assetName, description, serializeNodes, serializeEdges, navigate, nodes])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
|
|||||||
@@ -260,6 +260,21 @@ export default function NetworkDiagramsPage() {
|
|||||||
{d.description && (
|
{d.description && (
|
||||||
<p className="mb-3 line-clamp-2 text-xs text-muted-foreground">{d.description}</p>
|
<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 && (
|
{d.node_count > 0 && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
|
<TopologyBar categoryCounts={d.category_counts} nodeCount={d.node_count} />
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface NetworkDiagramListItem {
|
|||||||
description: string | null
|
description: string | null
|
||||||
node_count: number
|
node_count: number
|
||||||
category_counts: Record<string, number>
|
category_counts: Record<string, number>
|
||||||
|
thumbnail_url?: string | null
|
||||||
created_by: string | null
|
created_by: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
|||||||
Reference in New Issue
Block a user