Compare commits

...

25 Commits

Author SHA1 Message Date
3ad6d53261 Increase the size of select diagram and add .gitignore to backend 2025-07-13 05:43:49 +03:00
1d27e4841e Build backend and frontend and update the pipeline 2025-07-13 05:20:15 +03:00
95651d6a0a Change app icon 2025-07-13 02:10:40 +03:00
ce543dd347 Add confirm dialog 2025-07-13 00:53:52 +03:00
3058fe5285 Migrate nodes from default diagram to new created diagram 2025-07-13 00:03:30 +03:00
3b8fb6d55b Fix colors 2025-07-12 23:52:43 +03:00
32b6740669 Try to fix saved diagrams 2025-07-12 23:28:45 +03:00
1b5d7735ce Remove ColorNode 2025-07-12 22:06:06 +03:00
552f351949 Add more node type 2025-07-11 19:34:04 +03:00
a599e19ccc Add more node type 2025-07-11 19:29:12 +03:00
f48a5210b8 Fix iframe 2025-07-11 19:16:16 +03:00
05a8a64039 Set the buttons to not move 2025-07-11 19:06:32 +03:00
ccc429e258 Style buttons 2025-07-11 19:01:33 +03:00
b5d9631b66 Add unselect to node and edge 2025-07-11 18:46:05 +03:00
cd96cbf15d Style buttons 2025-07-11 18:44:10 +03:00
85e86798fc Style diagram selector 2025-07-11 18:22:55 +03:00
058614d634 Add diagrams options 2025-07-11 18:18:08 +03:00
7f199f3789 Add button to remove edge 2025-07-11 17:18:17 +03:00
55ff0b24d4 Change sizes 2025-07-11 17:02:10 +03:00
9989174c27 Increase the size of node and decrease the font 2025-07-11 16:49:57 +03:00
5cb2923859 Increase the size of node and decrease the font 2025-07-11 16:43:26 +03:00
4c6b651951 Increase the size of node and decrease the font 2025-07-11 16:42:19 +03:00
7c628f9d31 Increase the size of node and decrease the font 2025-07-11 16:39:56 +03:00
6a63755cef Increase the size of node and decrease the font 2025-07-11 16:36:32 +03:00
21b7acae5e Increase the size of node and decrease the font 2025-07-11 16:21:28 +03:00
17 changed files with 736 additions and 435 deletions

View File

@ -14,7 +14,7 @@ steps:
context: frontend context: frontend
tags: tags:
- latest - latest
- ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7} - ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
username: username:
from_secret: DOCKER_USERNAME from_secret: DOCKER_USERNAME
password: password:
@ -35,7 +35,7 @@ steps:
context: backend context: backend
tags: tags:
- latest - latest
- ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7} - ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}}
username: username:
from_secret: DOCKER_USERNAME from_secret: DOCKER_USERNAME
password: password:
@ -85,7 +85,6 @@ steps:
- apk add --no-cache git yq - apk add --no-cache git yq
- git config --global user.name "woodpecker-bot" - git config --global user.name "woodpecker-bot"
- git config --global user.email "ci@dvirlabs.com" - git config --global user.email "ci@dvirlabs.com"
- git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git"
- cd my-apps - cd my-apps
- | - |
TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}" TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
@ -94,3 +93,21 @@ steps:
git add manifests/${CI_REPO_NAME}/values.yaml git add manifests/${CI_REPO_NAME}/values.yaml
git commit -m "backend: update tag to $TAG" || echo "No changes" git commit -m "backend: update tag to $TAG" || echo "No changes"
git push origin HEAD git push origin HEAD
trigger-gitops-via-push:
name: Trigger apps-gitops via Git push
image: alpine/git
environment:
GIT_USERNAME:
from_secret: GIT_USERNAME
GIT_TOKEN:
from_secret: GIT_TOKEN
commands: |
git config --global user.name "woodpecker-bot"
git config --global user.email "ci@dvirlabs.com"
git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/apps-gitops.git"
cd apps-gitops
echo "# trigger at $(date) by $${CI_REPO_NAME}" >> .trigger
git add .trigger
git commit -m "ci: trigger apps-gitops build" || echo "no changes"
git push origin HEAD

1
backend/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
digrams/

View File

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

View File

@ -3,56 +3,71 @@ 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()
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], # בהמשך תוכל לצמצם לכתובת הפרונטאנד allow_origins=["*"],
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
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"
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):
if not name:
raise HTTPException(status_code=400, detail="Missing diagram name")
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():
"""
Returns a dictionary of available icons grouped by folder (category).
Example:
{
"dev-tools": [ "https://s3.dvirlabs.com/lab-icons/dev-tools/gitea.svg", ... ],
"observability": [ ... ]
}
"""
resp = requests.get(S3_INDEX_URL) resp = requests.get(S3_INDEX_URL)
if resp.status_code != 200: if resp.status_code != 200:
raise HTTPException(status_code=500, detail="Failed to fetch icon list from S3") raise HTTPException(status_code=500, detail="Failed to fetch icon list from S3")
@ -79,7 +94,5 @@ def list_icons():
return categories return categories
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

View File

@ -1,25 +0,0 @@
{
"nodes": [
{
"id": "1",
"type": "custom",
"data": {
"label": "Node 1",
"icon": "https://s3.dvirlabs.com/lab-icons/dev-tools/argocd.svg"
},
"position": {
"x": 68.5849844537563,
"y": 225.18693121594464
},
"width": 82,
"height": 82,
"selected": false,
"positionAbsolute": {
"x": 68.5849844537563,
"y": 225.18693121594464
},
"dragging": false
}
],
"edges": []
}

View File

@ -27,4 +27,3 @@ RUN dos2unix /docker-entrypoint.d/10-generate-env.sh && chmod +x /docker-entrypo
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -2,10 +2,10 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/labmap.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/env.js"></script> <script src="/env.js"></script>
<title>Vite + React</title> <title>Labmap</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@ -10,7 +10,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"prop-types": "^15.8.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-confirm-alert": "^3.0.6",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"reactflow": "^11.11.4" "reactflow": "^11.11.4"

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -27,8 +27,8 @@ function CustomNode({ data }) {
border: '2px solid #1976d2', border: '2px solid #1976d2',
borderRadius: 6, borderRadius: 6,
textAlign: 'center', textAlign: 'center',
width: 80, width: 100,
height: 80, height: 100,
boxShadow: '0 2px 6px rgba(0,0,0,0.2)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
position: 'relative', position: 'relative',
}}> }}>
@ -57,7 +57,7 @@ function CustomNode({ data }) {
}} }}
style={{ width: 40, height: 40, marginTop: 6 }} style={{ width: 40, height: 40, marginTop: 6 }}
/> />
<div style={{ fontWeight: 'bold', fontSize: 12 }}>{data.label}</div> <div style={{ fontWeight: 'bold', fontSize: 10 }}>{data.label}</div>
</div> </div>
); );
} }

View File

@ -7,49 +7,91 @@ 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 { confirmAlert } from 'react-confirm-alert';
import 'react-confirm-alert/src/react-confirm-alert.css';
import '../styles/ConfirmDialog.css';
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';
import RouterNode from './RouterNode';
const nodeTypes = { const nodeTypes = {
custom: CustomNode, custom: CustomNode,
router: RouterNode,
}; };
function Diagram() { function Diagram() {
const [nodes, setNodes, onNodesChange] = useNodesState([]); const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [selectedNode, setSelectedNode] = useState(null); const [selectedNode, setSelectedNode] = useState(null);
const [selectedEdge, setSelectedEdge] = useState(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
const [newType, setNewType] = useState('custom');
const [selectedIcon, setSelectedIcon] = useState(''); const [selectedIcon, setSelectedIcon] = useState('');
const [diagramName, setDiagramName] = useState(null);
const [diagramList, setDiagramList] = useState([]);
const isIframe = window.self !== window.top;
useEffect(() => { useEffect(() => {
fetchDiagram().then((data) => { const load = async () => {
setNodes(data.nodes || []); try {
const sanitizedEdges = (data.edges || []).map(({ id, source, target, animated }) => ({ const { diagrams } = await listDiagrams();
id, setDiagramList(diagrams);
source, if (diagrams.length > 0) {
target, setDiagramName(diagrams[0]);
animated: !!animated, } else {
})); setDiagramName("default"); // <- ensure we use this name
setEdges(sanitizedEdges); }
}); } catch (err) {
}, [setNodes, setEdges]); console.error(err);
setDiagramList([]);
setDiagramName("default"); // <- fallback to default always
}
};
load();
}, []);
useEffect(() => {
const loadDiagram = async () => {
if (!diagramName) {
setNodes([]);
setEdges([]);
return;
}
try {
const data = await fetchDiagramByName(diagramName);
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();
}, [diagramName]);
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, target, animated: !!animated
source,
target,
animated: !!animated,
})); }));
await saveDiagram({ nodes, edges: cleanedEdges }); await saveDiagramByName(diagramName, { nodes, edges: cleanedEdges });
toast.success('✅ Diagram saved!'); toast.success(`✅ Diagram "${diagramName}" saved!`);
};
const handleAddNode = () => {
setShowForm(true);
}; };
const handleSubmitNode = () => { const handleSubmitNode = () => {
@ -59,8 +101,8 @@ function Diagram() {
const newNode = { const newNode = {
id, id,
type: 'custom', type: newType,
data: { label, icon }, data: newType === 'custom' ? { label, icon } : { label },
position: { x: Math.random() * 400, y: Math.random() * 300 }, position: { x: Math.random() * 400, y: Math.random() * 300 },
}; };
@ -69,11 +111,11 @@ function Diagram() {
setShowForm(false); setShowForm(false);
setNewLabel(''); setNewLabel('');
setSelectedIcon(''); setSelectedIcon('');
setNewType('custom');
}; };
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`);
@ -85,7 +127,21 @@ function Diagram() {
[setEdges] [setEdges]
); );
const handleNodeDoubleClick = (event, node) => { const onEdgeClick = (_, edge) => {
setSelectedEdge(edge);
setSelectedNode(null);
};
const handleDeleteEdge = () => {
if (!selectedEdge) return;
setEdges((eds) => eds.filter((e) => e.id !== selectedEdge.id));
toast.error(`🗑️ Edge "${selectedEdge.id}" deleted`);
setSelectedEdge(null);
};
const onNodeClick = (_, node) => setSelectedNode(node);
const handleNodeDoubleClick = (_, node) => {
const newLabel = prompt('Enter new name:', node.data.label); const newLabel = prompt('Enter new name:', node.data.label);
if (newLabel !== null) { if (newLabel !== null) {
setNodes((nds) => setNodes((nds) =>
@ -97,73 +153,145 @@ function Diagram() {
} }
}; };
const onNodeClick = (_, node) => { const handleDeleteDiagram = async () => {
setSelectedNode(node); if (!diagramName || diagramName === 'default') {
toast.warn("❌ Cannot delete 'default' diagram or nothing selected.");
return;
}
confirmAlert({
title: 'Are you sure?',
message: `Delete "${diagramName}"?`,
buttons: [
{
label: 'Yes',
onClick: async () => {
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}"`);
}
}
},
{
label: 'No',
onClick: () => {} // do nothing
}
]
});
}; };
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, display: 'flex', alignItems: 'center', gap: 10 }}>
<button onClick={handleSave} className="btn">💾 Save</button> <div style={{ position: 'relative' }}>
<button onClick={handleAddNode} className="btn" style={{ marginLeft: 8 }}> Add Node</button> <select
<button onClick={handleDeleteNode} className="btn" style={{ marginLeft: 8 }} disabled={!selectedNode}>🗑 Delete Node</button> value={diagramName || ''}
onChange={(e) => setDiagramName(e.target.value)}
style={{
padding: '10px 16px',
height: '48px',
fontSize: '16px',
fontWeight: 'bold',
borderRadius: '12px',
border: 'none',
background: '#111',
color: 'white',
cursor: 'pointer',
appearance: 'none',
width: 230,
paddingRight: 32,
}}
>
{diagramList.map((name) => (
<option key={name} value={name}>
📌 {name}
</option>
))}
</select>
<div style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none', color: 'white' }}>
</div>
</div>
{!isIframe && (
<>
<button className="btn" style={{ background: '#d28519' }} onClick={async () => {
const newName = prompt("Enter new diagram name:");
if (!newName) return;
if (diagramList.includes(newName)) {
toast.warn("❗ Diagram already exists.");
return;
}
try {
// Save current content (only if in 'default')
if (diagramName === 'default' && (nodes.length > 0 || edges.length > 0)) {
const cleanedEdges = edges.map(({ id, source, target, animated }) => ({
id, source, target, animated: !!animated
}));
await saveDiagramByName(newName, { nodes, edges: cleanedEdges });
toast.success(`📁 Migrated content from 'default' to '${newName}'`);
}
setDiagramList((prev) => [...prev, newName]);
setDiagramName(newName); // <- only after saving
} catch (err) {
toast.error(`❌ Failed to migrate default diagram`);
console.error(err);
}
}}>
🆕 New Diagram
</button>
{diagramName !== 'default' && (
<button className="btn" onClick={handleSave} style={{ background: 'green' }}>💾 Save</button>
)}
<button className="btn" onClick={() => setShowForm(true)} style={{ background: 'blue' }}> Add Node</button>
<button className="btn" onClick={handleDeleteNode} style={{ color: 'white', background: selectedNode ? '#b81a1a' : '#424040' }} disabled={!selectedNode}>🗑 Delete Node</button>
<button className="btn" onClick={handleDeleteEdge} style={{ color: 'white', background: selectedEdge ? '#b81a1a' : '#424040' }} disabled={!selectedEdge}>🗑 Delete Edge</button>
<button className="btn" onClick={handleDeleteDiagram} style={{ background: 'red' }}>🗑 Delete Diagram</button>
</>
)}
</div> </div>
{showForm && ( {showForm && (
<div <div style={{ position: 'absolute', zIndex: 20, right: 10, top: 80, background: '#13141a', padding: '16px', borderRadius: '12px', boxShadow: '0 4px 8px rgba(0,0,0,0.15)', width: 280 }}>
style={{ <h4 style={{ color: 'white', marginBottom: 12 }}>🧩 Add New Node</h4>
position: 'absolute',
zIndex: 20,
right: 10,
top: 80,
background: '#e6f0fa',
padding: '16px',
borderRadius: '12px',
boxShadow: '0 4px 8px rgba(0,0,0,0.15)',
width: 280,
}}
>
<h4
style={{
color: '#125ea8',
marginBottom: 12,
fontSize: 18,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
🧩 Add New Node
</h4>
<label style={{ fontWeight: 'bold', marginBottom: 4, display: 'block', color: '#333' }}> <label style={{ fontWeight: 'bold', color: 'white' }}>Label:</label>
Label:
</label>
<input <input
type="text" type="text"
placeholder="Enter label"
value={newLabel} value={newLabel}
onChange={(e) => setNewLabel(e.target.value)} onChange={(e) => setNewLabel(e.target.value)}
style={{ style={{ padding: '6px', width: '100%', borderRadius: 6, border: '1px solid #ccc', marginBottom: 12 }}
padding: '6px',
width: '100%',
borderRadius: 6,
border: '1px solid #ccc',
marginBottom: 12,
}}
/> />
<IconSelector onSelect={setSelectedIcon} /> <label style={{ fontWeight: 'bold', color: 'white' }}>Type:</label>
<select
value={newType}
onChange={(e) => setNewType(e.target.value)}
style={{ padding: '6px', width: '100%', borderRadius: 6, border: '1px solid #ccc', marginBottom: 12 }}
>
<option value="custom">🧱 Custom</option>
<option value="router">📡 Router</option>
</select>
{newType === 'custom' && <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)}> Cancel</button>
className="btn"
style={{ marginLeft: 8, background: '#ccc', color: '#333' }}
onClick={() => setShowForm(false)}
>
Cancel
</button>
</div> </div>
</div> </div>
)} )}
@ -176,6 +304,11 @@ function Diagram() {
onConnect={onConnect} onConnect={onConnect}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodeDoubleClick={handleNodeDoubleClick} onNodeDoubleClick={handleNodeDoubleClick}
onEdgeClick={onEdgeClick}
onPaneClick={() => {
setSelectedNode(null);
setSelectedEdge(null);
}}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
> >

View File

@ -25,7 +25,7 @@ function IconSelector({ onSelect }) {
return ( return (
<div style={{ marginBottom: '1rem' }}> <div style={{ marginBottom: '1rem' }}>
<div style={{ marginBottom: '0.5rem' }}> <div style={{ marginBottom: '0.5rem' }}>
<label style={{ fontWeight: 'bold', color: '#333' }}>Category:&nbsp;</label> <label style={{ fontWeight: 'bold', color: 'white' }}>Category:&nbsp;</label>
<select <select
value={selectedCategory} value={selectedCategory}
onChange={handleCategoryChange} onChange={handleCategoryChange}
@ -47,7 +47,7 @@ function IconSelector({ onSelect }) {
{selectedCategory && ( {selectedCategory && (
<div> <div>
<label style={{ fontWeight: 'bold', color: 'black' }}>Choose an icon:</label> <label style={{ fontWeight: 'bold', color: 'white' }}>Choose an icon:</label>
<div <div
style={{ style={{
display: 'grid', display: 'grid',
@ -81,7 +81,7 @@ function IconSelector({ onSelect }) {
))} ))}
</div> </div>
{selectedIcon && ( {selectedIcon && (
<p style={{ marginTop: 8, fontSize: 13, color: 'black' }}> <p style={{ marginTop: 8, fontSize: 13, color: 'white' }}>
Selected: <code>{selectedIcon.split('/').pop()}</code> Selected: <code>{selectedIcon.split('/').pop()}</code>
</p> </p>
)} )}

View File

@ -0,0 +1,14 @@
// components/RouterNode.js
import { Handle, Position } from 'reactflow';
function RouterNode({ data }) {
return (
<div style={{ border: '2px dashed #ff9800', borderRadius: 6, padding: 10, width: 100, textAlign: 'center' }}>
<Handle type="target" position={Position.Top} />
<div>📡 {data.label}</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
}
export default RouterNode;

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();
}

View File

@ -0,0 +1,24 @@
.react-confirm-alert-overlay {
background: rgba(0, 0, 0, 0.6); /* כהה ונעים לעין */
z-index: 1000;
}
.react-confirm-alert-body {
background-color: #e6e3e3;
color: #333;
}
.react-confirm-alert-button-group button {
margin: 0 10px;
padding: 8px 18px;
border: none;
border-radius: 6px;
font-weight: bold;
background-color: #333;
color: white;
cursor: pointer;
}
.react-confirm-alert-button-group button:hover {
background-color: #555;
}