Add diagrams options

This commit is contained in:
dvirlabs 2025-07-11 18:18:08 +03:00
parent 7f199f3789
commit 058614d634
7 changed files with 227 additions and 76 deletions

View File

@ -0,0 +1,19 @@
{
"nodes": [
{
"id": "1",
"type": "custom",
"data": {
"label": "Node 1",
"icon": "https://s3.dvirlabs.com/lab-icons/dev-tools/argocd.svg"
},
"position": {
"x": 307.58165778037267,
"y": 195.1586839847007
},
"width": 103,
"height": 103
}
],
"edges": []
}

View File

@ -0,0 +1,19 @@
{
"nodes": [
{
"id": "1",
"type": "custom",
"data": {
"label": "Node 1",
"icon": "https://s3.dvirlabs.com/lab-icons/infra/cloudflare.svg"
},
"position": {
"x": 19.293138020977764,
"y": 245.65982158849926
},
"width": 103,
"height": 103
}
],
"edges": []
}

View File

@ -3,10 +3,10 @@ from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from models import DiagramItem from models import DiagramItem
import json import json
import os
import requests import requests
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from typing import List, Dict from typing import List, Dict
from pathlib import Path
app = FastAPI() app = FastAPI()
@ -17,31 +17,54 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Static icon info
BASE_URL = "https://s3.dvirlabs.com/lab-icons" BASE_URL = "https://s3.dvirlabs.com/lab-icons"
S3_INDEX_URL = "https://s3.dvirlabs.com/lab-icons/?list-type=2" S3_INDEX_URL = "https://s3.dvirlabs.com/lab-icons/?list-type=2"
DATA_FILE = "diagram.json"
# Directory for storing diagrams
BASE_DIR = Path(__file__).parent.resolve()
DATA_DIR = BASE_DIR / "diagrams"
DATA_DIR.mkdir(exist_ok=True)
@app.get("/") @app.get("/")
def root(): def root():
return {"message": "Check if the server is running"} return {"message": "Check if the server is running"}
@app.get("/diagram/fetch") @app.get("/diagram/fetch")
def fetch_diagram(): def fetch_diagram(name: str):
if not os.path.exists(DATA_FILE): path = DATA_DIR / f"diagram_{name}.json"
if not path.exists():
return {"nodes": [], "edges": []} return {"nodes": [], "edges": []}
with open(DATA_FILE, "r") as f: with open(path, "r") as f:
return json.load(f) return json.load(f)
@app.post("/diagram/save") @app.post("/diagram/save")
def save_diagram(payload: DiagramItem): def save_diagram(name: str, payload: DiagramItem):
try: try:
with open(DATA_FILE, "w") as f: path = DATA_DIR / f"diagram_{name}.json"
with open(path, "w") as f:
json.dump(payload.dict(), f, indent=2) json.dump(payload.dict(), f, indent=2)
return {"status": "ok"} return {"status": "ok"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/diagram/list")
def list_diagrams():
diagrams = []
for file in DATA_DIR.glob("diagram_*.json"):
diagrams.append(file.stem.replace("diagram_", ""))
return {"diagrams": diagrams}
@app.delete("/diagram/delete")
def delete_diagram(name: str):
path = DATA_DIR / f"diagram_{name}.json"
if not path.exists():
raise HTTPException(status_code=404, detail="Diagram not found")
try:
path.unlink()
return {"status": "deleted"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/icons", response_model=Dict[str, List[str]]) @app.get("/icons", response_model=Dict[str, List[str]])
def list_icons(): def list_icons():
@ -79,10 +102,5 @@ def list_icons():
return categories return categories
# if __name__ == "__main__":
# uvicorn.run(app, host="0.0.0.0", port=8000)
# For development, you can uncomment the line below to run the server with uvicorn directly.
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8001) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@ -1,4 +0,0 @@
{
"nodes": [],
"edges": []
}

View File

@ -7,7 +7,12 @@ import ReactFlow, {
useEdgesState, useEdgesState,
} from 'reactflow'; } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { fetchDiagram, saveDiagram } from '../services/api'; import {
fetchDiagramByName,
saveDiagramByName,
listDiagrams,
deleteDiagramByName,
} from '../services/api';
import CustomNode from './CustomNode'; import CustomNode from './CustomNode';
import IconSelector from './IconSelector'; import IconSelector from './IconSelector';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@ -24,30 +29,84 @@ function Diagram() {
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
const [selectedIcon, setSelectedIcon] = useState(''); const [selectedIcon, setSelectedIcon] = useState('');
const [selectedEdge, setSelectedEdge] = useState(null); const [selectedEdge, setSelectedEdge] = useState(null);
const [diagramName, setDiagramName] = useState(null);
const [diagramList, setDiagramList] = useState([]);
useEffect(() => { useEffect(() => {
fetchDiagram().then((data) => { let isMounted = true;
setNodes(data.nodes || []);
const sanitizedEdges = (data.edges || []).map(({ id, source, target, animated }) => ({ const load = async () => {
id, try {
source, const { diagrams } = await listDiagrams();
target, if (isMounted) {
animated: !!animated, setDiagramList(diagrams);
}));
setEdges(sanitizedEdges); if (diagrams.length > 0) {
}); setDiagramName((prev) => prev || diagrams[0]);
}, [setNodes, setEdges]); } else {
setDiagramName(null);
}
}
} catch (err) {
console.error(err);
setDiagramList([]);
setDiagramName(null);
}
};
load();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
let isMounted = true;
const loadDiagram = async () => {
if (!diagramName) {
setNodes([]);
setEdges([]);
return;
}
try {
const data = await fetchDiagramByName(diagramName);
if (isMounted) {
setNodes(data.nodes || []);
const sanitizedEdges = (data.edges || []).map(({ id, source, target, animated }) => ({
id,
source,
target,
animated: !!animated,
}));
setEdges(sanitizedEdges);
setSelectedNode(null);
setSelectedEdge(null);
}
} catch (err) {
console.error(err);
toast.error(`❌ Failed to load diagram "${diagramName}"`);
setNodes([]);
setEdges([]);
}
};
loadDiagram();
return () => {
isMounted = false;
};
}, [diagramName, setNodes, setEdges]);
const handleSave = async () => { const handleSave = async () => {
if (!diagramName) return;
const cleanedEdges = edges.map(({ id, source, target, animated }) => ({ const cleanedEdges = edges.map(({ id, source, target, animated }) => ({
id, id,
source, source,
target, target,
animated: !!animated, animated: !!animated,
})); }));
await saveDiagram({ nodes, edges: cleanedEdges }); await saveDiagramByName(diagramName, { nodes, edges: cleanedEdges });
toast.success('✅ Diagram saved!'); toast.success(`✅ Diagram "${diagramName}" saved!`);
}; };
const handleAddNode = () => { const handleAddNode = () => {
@ -75,7 +134,6 @@ function Diagram() {
const handleDeleteNode = () => { const handleDeleteNode = () => {
if (!selectedNode) return; if (!selectedNode) return;
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id)); setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id)); setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id));
toast.error(`🗑️ Node "${selectedNode.data.label}" deleted`); toast.error(`🗑️ Node "${selectedNode.data.label}" deleted`);
@ -89,7 +147,6 @@ function Diagram() {
const handleDeleteEdge = () => { const handleDeleteEdge = () => {
if (!selectedEdge) return; if (!selectedEdge) return;
setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id)); setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id));
toast.error(`🗑️ Edge "${selectedEdge.id}" deleted`); toast.error(`🗑️ Edge "${selectedEdge.id}" deleted`);
setSelectedEdge(null); setSelectedEdge(null);
@ -116,13 +173,65 @@ function Diagram() {
setSelectedNode(node); setSelectedNode(node);
}; };
const handleDeleteDiagram = async () => {
if (!diagramName || diagramName === 'default') {
toast.warn("❌ Cannot delete 'default' diagram or nothing selected.");
return;
}
const confirmDelete = confirm(`Are you sure you want to delete diagram "${diagramName}"?`);
if (!confirmDelete) return;
try {
await deleteDiagramByName(diagramName);
const updatedList = diagramList.filter(name => name !== diagramName);
setDiagramList(updatedList);
const newSelected = updatedList[0] || null;
setDiagramName(newSelected);
setNodes([]);
setEdges([]);
toast.success(`🗑️ Diagram "${diagramName}" deleted`);
} catch (err) {
toast.error(`❌ Failed to delete diagram "${diagramName}"`);
}
};
return ( return (
<div style={{ width: '100vw', height: '100vh' }}> <div style={{ width: '100vw', height: '100vh' }}>
<div style={{ position: 'absolute', zIndex: 10, right: 10, top: 10 }}> <div style={{ position: 'absolute', zIndex: 10, right: 10, top: 10 }}>
<button onClick={handleSave} className="btn">💾 Save</button> <select
value={diagramName || ''}
onChange={(e) => setDiagramName(e.target.value)}
style={{ marginRight: 8, padding: 6 }}
>
{diagramList.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
<button
className="btn"
onClick={() => {
const newName = prompt("Enter new diagram name:");
if (newName) {
if (!diagramList.includes(newName)) {
const updatedList = [...diagramList, newName];
setDiagramList(updatedList);
}
setDiagramName(newName);
setNodes([]);
setEdges([]);
setSelectedNode(null);
setSelectedEdge(null);
}
}}
>
🆕 New Diagram
</button>
<button onClick={handleSave} className="btn" style={{ marginLeft: 8 }}>💾 Save</button>
<button onClick={handleAddNode} className="btn" style={{ marginLeft: 8 }}> Add Node</button> <button onClick={handleAddNode} className="btn" style={{ marginLeft: 8 }}> Add Node</button>
<button onClick={handleDeleteNode} className="btn" style={{ marginLeft: 8 }} disabled={!selectedNode}>🗑 Delete Node</button> <button onClick={handleDeleteNode} className="btn" style={{ marginLeft: 8 }} disabled={!selectedNode}>🗑 Delete Node</button>
<button onClick={handleDeleteEdge} className="btn" style={{ marginLeft: 8 }} disabled={!selectedEdge}>🗑 Delete Edge</button> <button onClick={handleDeleteEdge} className="btn" style={{ marginLeft: 8 }} disabled={!selectedEdge}>🗑 Delete Edge</button>
<button onClick={handleDeleteDiagram} className="btn" style={{ marginLeft: 8 }}>🗑 Delete Diagram</button>
</div> </div>
{showForm && ( {showForm && (
@ -139,19 +248,9 @@ function Diagram() {
width: 280, width: 280,
}} }}
> >
<h4 <h4 style={{ color: '#125ea8', marginBottom: 12, fontSize: 18, display: 'flex', alignItems: 'center', gap: 8 }}>
style={{
color: '#125ea8',
marginBottom: 12,
fontSize: 18,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
🧩 Add New Node 🧩 Add New Node
</h4> </h4>
<label style={{ fontWeight: 'bold', marginBottom: 4, display: 'block', color: '#333' }}> <label style={{ fontWeight: 'bold', marginBottom: 4, display: 'block', color: '#333' }}>
Label: Label:
</label> </label>
@ -168,16 +267,10 @@ function Diagram() {
marginBottom: 12, marginBottom: 12,
}} }}
/> />
<IconSelector onSelect={setSelectedIcon} /> <IconSelector onSelect={setSelectedIcon} />
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<button className="btn" onClick={handleSubmitNode}> Add</button> <button className="btn" onClick={handleSubmitNode}> Add</button>
<button <button className="btn" style={{ marginLeft: 8, background: '#ccc', color: '#333' }} onClick={() => setShowForm(false)}>
className="btn"
style={{ marginLeft: 8, background: '#ccc', color: '#333' }}
onClick={() => setShowForm(false)}
>
Cancel Cancel
</button> </button>
</div> </div>

View File

@ -1,27 +1,5 @@
const API_BASE = window?.ENV?.API_BASE || ""; const API_BASE = window?.ENV?.API_BASE || "";
export async function fetchDiagram() {
const res = await fetch(`${API_BASE}/diagram/fetch`);
if (!res.ok) {
throw new Error(`Failed to fetch diagram: ${res.status}`);
}
return await res.json();
}
export async function saveDiagram(data) {
const res = await fetch(`${API_BASE}/diagram/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) {
throw new Error(`Failed to save diagram: ${res.status}`);
}
return await res.json();
}
export async function fetchIconCategories() { export async function fetchIconCategories() {
const res = await fetch(`${API_BASE}/icons`); const res = await fetch(`${API_BASE}/icons`);
if (!res.ok) { if (!res.ok) {
@ -30,4 +8,32 @@ export async function fetchIconCategories() {
return await res.json(); return await res.json();
} }
export async function listDiagrams() {
const res = await fetch(`${API_BASE}/diagram/list`);
if (!res.ok) throw new Error("Failed to list diagrams");
return await res.json();
}
export async function fetchDiagramByName(name) {
const res = await fetch(`${API_BASE}/diagram/fetch?name=${name}`);
if (!res.ok) throw new Error(`Failed to load diagram "${name}"`);
return await res.json();
}
export async function saveDiagramByName(name, data) {
const res = await fetch(`${API_BASE}/diagram/save?name=${name}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`Failed to save diagram "${name}"`);
return await res.json();
}
export async function deleteDiagramByName(name) {
const res = await fetch(`${API_BASE}/diagram/delete?name=${name}`, {
method: "DELETE",
});
if (!res.ok) throw new Error(`Failed to delete diagram "${name}"`);
return await res.json();
}