Compare commits

..

No commits in common. "master" and "test-branch" have entirely different histories.

25 changed files with 490 additions and 849 deletions

View File

@ -9,16 +9,18 @@ steps:
include: [ frontend/** ] include: [ frontend/** ]
settings: settings:
registry: harbor.dvirlabs.com registry: harbor.dvirlabs.com
repo: my-apps/${CI_REPO_NAME}-frontend repo: my-apps/labmap-frontend
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
context: frontend context: frontend
tags: tags:
- latest - latest
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}} - ${CI_COMMIT_SHA:0:7}
- ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}
username: username:
from_secret: DOCKER_USERNAME from_secret: DOCKER_USERNAME
password: password:
from_secret: DOCKER_PASSWORD from_secret: DOCKER_PASSWORD
build-backend: build-backend:
name: Build & Push Backend name: Build & Push Backend
@ -30,12 +32,13 @@ steps:
include: [ backend/** ] include: [ backend/** ]
settings: settings:
registry: harbor.dvirlabs.com registry: harbor.dvirlabs.com
repo: my-apps/${CI_REPO_NAME}-backend repo: my-apps/labmap-backend
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
context: backend context: backend
tags: tags:
- latest - latest
- ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}} - ${CI_COMMIT_SHA:0:7}
- ${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}
username: username:
from_secret: DOCKER_USERNAME from_secret: DOCKER_USERNAME
password: password:
@ -61,12 +64,16 @@ steps:
- git clone "https://$${GIT_USERNAME}:$${GIT_TOKEN}@git.dvirlabs.com/dvirlabs/my-apps.git" - 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}" if [ "$CI_COMMIT_BRANCH" = "develop" ]; then
TAG="develop-${CI_BUILD_NUMBER}"
else
TAG="${CI_COMMIT_SHA:0:7}"
fi
echo "💡 Setting frontend tag to: $TAG" echo "💡 Setting frontend tag to: $TAG"
yq -i ".frontend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml yq -i ".frontend.tag = \"$TAG\"" manifests/labmap/values.yaml
git add manifests/${CI_REPO_NAME}/values.yaml git add manifests/labmap/values.yaml
git commit -m "frontend: update tag to $TAG" || echo "No changes" git commit -m "frontend: update tag to $TAG" || echo "No changes"
git push origin HEAD git push origin master
update-values-backend: update-values-backend:
name: Update backend tag in values.yaml name: Update backend tag in values.yaml
@ -85,29 +92,16 @@ 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}" if [ "$CI_COMMIT_BRANCH" = "develop" ]; then
TAG="develop-${CI_BUILD_NUMBER}"
else
TAG="${CI_COMMIT_SHA:0:7}"
fi
echo "💡 Setting backend tag to: $TAG" echo "💡 Setting backend tag to: $TAG"
yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml yq -i ".backend.tag = \"$TAG\"" manifests/labmap/values.yaml
git add manifests/${CI_REPO_NAME}/values.yaml git add manifests/labmap/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 master
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
View File

@ -1 +0,0 @@
digrams/

View File

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

View File

@ -3,71 +3,57 @@ 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_credentials=True,
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(name: str): def fetch_diagram():
path = DATA_DIR / f"diagram_{name}.json" if not os.path.exists(DATA_FILE):
if not path.exists():
return {"nodes": [], "edges": []} return {"nodes": [], "edges": []}
with open(path, "r") as f: with open(DATA_FILE, "r") as f:
return json.load(f) return json.load(f)
@app.post("/diagram/save") @app.post("/diagram/save")
def save_diagram(name: str, payload: DiagramItem): def save_diagram(payload: DiagramItem):
if not name:
raise HTTPException(status_code=400, detail="Missing diagram name")
try: try:
path = DATA_DIR / f"diagram_{name}.json" with open(DATA_FILE, "w") as f:
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")
@ -94,5 +80,6 @@ def list_icons():
return categories return categories
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) uvicorn.run(app, host="0.0.0.0", port=8000)

36
diagram.json Normal file
View File

@ -0,0 +1,36 @@
{
"nodes": [
{
"id": "1",
"type": "custom",
"data": {
"label": "ArgoCD",
"icon": "https://s3.dvirlabs.com/lab-icons/argocd.svg"
},
"position": {
"x": 100,
"y": 100
}
},
{
"id": "2",
"type": "custom",
"data": {
"label": "Gitea",
"icon": "https://s3.dvirlabs.com/lab-icons/gitea.svg"
},
"position": {
"x": 300,
"y": 200
}
}
],
"edges": [
{
"id": "e1-2",
"source": "1",
"target": "2",
"animated": true
}
]
}

3
frontend/.gitignore vendored
View File

@ -22,6 +22,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Custom
public/env.js

View File

@ -1,4 +0,0 @@
#!/bin/sh
# Generate env.js from the template
envsubst < /etc/env/env.js.template > /usr/share/nginx/html/env.js

View File

@ -3,6 +3,7 @@ FROM node:20 AS builder
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN npm install RUN npm install
RUN npm run build RUN npm run build
@ -15,15 +16,15 @@ RUN apk add --no-cache dos2unix
# Copy built app # Copy built app
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# ✅ Copy env.js.template # Copy nginx config
COPY public/env.js.template /etc/env/env.js.template
# ✅ Copy nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
# ✅ Add env generator script to nginx entrypoint hook # Copy runtime env template + entrypoint
COPY 10-generate-env.sh /docker-entrypoint.d/10-generate-env.sh COPY public/env.js.template /usr/share/nginx/html/env.js.template
RUN dos2unix /docker-entrypoint.d/10-generate-env.sh && chmod +x /docker-entrypoint.d/10-generate-env.sh COPY docker-entrypoint.sh /entrypoint.sh
# Normalize line endings and set permissions
RUN dos2unix /entrypoint.sh && chmod +x /entrypoint.sh
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] ENTRYPOINT ["/entrypoint.sh"]

View File

@ -0,0 +1,9 @@
#!/bin/sh
cat <<EOF > /usr/share/nginx/html/env.js
window.ENV = {
API_BASE: "${API_BASE:-http://localhost:8000}"
};
EOF
exec 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="/labmap.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.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="%PUBLIC_URL%/env.js"></script>
<title>Labmap</title> <title>Vite + React</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,11 +10,8 @@
"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",
"reactflow": "^11.11.4" "reactflow": "^11.11.4"
}, },
"devDependencies": { "devDependencies": {

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 38 KiB

View File

@ -1,14 +1,7 @@
import Diagram from './components/Diagram'; import Diagram from './components/Diagram';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
function App() { function App() {
return ( return <Diagram />;
<>
<Diagram />
<ToastContainer position="bottom-right" autoClose={3000} />
</>
);
} }
export default App; export default App;

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<path d="M3 9h18M9 21V9" />
</svg>

Before

Width:  |  Height:  |  Size: 271 B

View File

@ -3,61 +3,31 @@ import { Handle, Position } from 'reactflow';
function CustomNode({ data }) { function CustomNode({ data }) {
const icon = data.icon || 'https://s3.dvirlabs.com/lab-icons/default.svg'; const icon = data.icon || 'https://s3.dvirlabs.com/lab-icons/default.svg';
const handleSize = 5;
const visibleHandleStyle = {
background: '#1976d2',
width: handleSize,
height: handleSize,
borderRadius: '50%',
zIndex: 2,
};
const invisibleHandleStyle = {
background: 'transparent',
width: handleSize,
height: handleSize,
borderRadius: '50%',
zIndex: 1,
};
return ( return (
<div style={{ <div style={{
background: 'transparent', background: 'transparent',
border: '2px solid #1976d2', border: '2px solid #1976d2',
borderRadius: 6, borderRadius: 6,
padding: 10,
textAlign: 'center', textAlign: 'center',
width: 100, width: 80,
height: 100, height: 40,
boxShadow: '0 2px 6px rgba(0,0,0,0.2)', boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
position: 'relative', position: 'relative',
}}> }}>
{/* === TOP === */} {/* Handles - חובה */}
<Handle id="source-top" type="source" position={Position.Top} style={{ ...visibleHandleStyle, top: -6 }} /> <Handle type="target" position={Position.Top} />
<Handle id="target-top" type="target" position={Position.Top} style={{ ...invisibleHandleStyle, top: -6 }} /> <Handle type="source" position={Position.Bottom} />
{/* === BOTTOM === */}
<Handle id="source-bottom" type="source" position={Position.Bottom} style={{ ...visibleHandleStyle, bottom: -6 }} />
<Handle id="target-bottom" type="target" position={Position.Bottom} style={{ ...invisibleHandleStyle, bottom: -6 }} />
{/* === LEFT === */}
<Handle id="source-left" type="source" position={Position.Left} style={{ ...visibleHandleStyle, left: -6, top: 30 }} />
<Handle id="target-left" type="target" position={Position.Left} style={{ ...invisibleHandleStyle, left: -6, top: 30 }} />
{/* === RIGHT === */}
<Handle id="source-right" type="source" position={Position.Right} style={{ ...visibleHandleStyle, right: -6, top: 30 }} />
<Handle id="target-right" type="target" position={Position.Right} style={{ ...invisibleHandleStyle, right: -6, top: 30 }} />
{/* === ICON + LABEL === */}
<img <img
src={icon} src={icon}
alt={data.label} alt={data.label}
onError={(e) => { onError={(e) => {
e.target.src = 'https://s3.dvirlabs.com/lab-icons/default.svg'; e.target.src = 'https://s3.dvirlabs.com/lab-icons/default.svg';
}} }}
style={{ width: 40, height: 40, marginTop: 6 }} style={{ width: 40, height: 40, marginBottom: 6 }}
/> />
<div style={{ fontWeight: 'bold', fontSize: 10 }}>{data.label}</div> <div style={{ fontWeight: 'bold' }}>{data.label}</div>
</div> </div>
); );
} }

View File

@ -1,3 +1,4 @@
// src/components/Diagram.jsx
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import ReactFlow, { import ReactFlow, {
addEdge, addEdge,
@ -7,91 +8,48 @@ import ReactFlow, {
useEdgesState, useEdgesState,
} from 'reactflow'; } from 'reactflow';
import 'reactflow/dist/style.css'; import 'reactflow/dist/style.css';
import { confirmAlert } from 'react-confirm-alert'; import { fetchDiagram, saveDiagram } from '../services/api';
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 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(() => {
const load = async () => { fetchDiagram().then((data) => {
try { setNodes(data.nodes || []);
const { diagrams } = await listDiagrams(); const sanitizedEdges = (data.edges || []).map(({ id, source, target, animated }) => ({
setDiagramList(diagrams); id,
if (diagrams.length > 0) { source,
setDiagramName(diagrams[0]); target,
} else { animated: !!animated,
setDiagramName("default"); // <- ensure we use this name }));
} setEdges(sanitizedEdges);
} catch (err) { });
console.error(err); }, [setNodes, setEdges]);
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, source, target, animated: !!animated id,
source,
target,
animated: !!animated,
})); }));
await saveDiagramByName(diagramName, { nodes, edges: cleanedEdges }); await saveDiagram({ nodes, edges: cleanedEdges });
toast.success(`✅ Diagram "${diagramName}" saved!`); alert('Diagram saved!');
};
const handleAddNode = () => {
setShowForm(true);
}; };
const handleSubmitNode = () => { const handleSubmitNode = () => {
@ -101,24 +59,21 @@ function Diagram() {
const newNode = { const newNode = {
id, id,
type: newType, type: 'custom',
data: newType === 'custom' ? { label, icon } : { label }, data: { label, icon },
position: { x: Math.random() * 400, y: Math.random() * 300 }, position: { x: Math.random() * 400, y: Math.random() * 300 },
}; };
setNodes((nds) => [...nds, newNode]); setNodes((nds) => [...nds, newNode]);
toast.success(`🟢 Node "${label}" added`);
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`);
setSelectedNode(null); setSelectedNode(null);
}; };
@ -127,21 +82,7 @@ function Diagram() {
[setEdges] [setEdges]
); );
const onEdgeClick = (_, edge) => { const handleNodeDoubleClick = (event, node) => {
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) =>
@ -149,149 +90,76 @@ function Diagram() {
n.id === node.id ? { ...n, data: { ...n.data, label: newLabel } } : n n.id === node.id ? { ...n, data: { ...n.data, label: newLabel } } : n
) )
); );
toast.info(`✏️ Node renamed to "${newLabel}"`);
} }
}; };
const handleDeleteDiagram = async () => { const onNodeClick = (_, node) => {
if (!diagramName || diagramName === 'default') { setSelectedNode(node);
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, display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ position: 'absolute', zIndex: 10, right: 10, top: 10 }}>
<div style={{ position: 'relative' }}> <button onClick={handleSave} className="btn">💾 Save</button>
<select <button onClick={handleAddNode} className="btn" style={{ marginLeft: 8 }}> Add Node</button>
value={diagramName || ''} <button onClick={handleDeleteNode} className="btn" style={{ marginLeft: 8 }} disabled={!selectedNode}>🗑 Delete Node</button>
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 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 }}> <div
<h4 style={{ color: 'white', marginBottom: 12 }}>🧩 Add New Node</h4> style={{
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', color: 'white' }}>Label:</label> <label style={{ fontWeight: 'bold', marginBottom: 4, display: 'block', color: '#333' }}>
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={{ padding: '6px', width: '100%', borderRadius: 6, border: '1px solid #ccc', marginBottom: 12 }} style={{
padding: '6px',
width: '100%',
borderRadius: 6,
border: '1px solid #ccc',
marginBottom: 12,
}}
/> />
<label style={{ fontWeight: 'bold', color: 'white' }}>Type:</label> <IconSelector onSelect={setSelectedIcon} />
<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 className="btn" style={{ marginLeft: 8, background: '#ccc', color: '#333' }} onClick={() => setShowForm(false)}> Cancel</button> <button
className="btn"
style={{ marginLeft: 8, background: '#ccc', color: '#333' }}
onClick={() => setShowForm(false)}
>
Cancel
</button>
</div> </div>
</div> </div>
)} )}
@ -304,11 +172,6 @@ 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: 'white' }}>Category:&nbsp;</label> <label style={{ fontWeight: 'bold', color: '#333' }}>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: 'white' }}>Choose an icon:</label> <label style={{ fontWeight: 'bold' }}>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: 'white' }}> <p style={{ marginTop: 8, fontSize: 13 }}>
Selected: <code>{selectedIcon.split('/').pop()}</code> Selected: <code>{selectedIcon.split('/').pop()}</code>
</p> </p>
)} )}

View File

@ -1,14 +0,0 @@
// 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,39 +1,20 @@
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`);
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),
});
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) {
throw new Error(`Failed to fetch icons: ${res.status}`);
}
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(); return await res.json();
} }

View File

@ -1,24 +0,0 @@
.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;
}

View File

@ -1,7 +1,7 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'; import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
}); })

View File

@ -19,4 +19,4 @@ spec:
- containerPort: {{ .Values.frontend.port }} - containerPort: {{ .Values.frontend.port }}
env: env:
- name: API_BASE - name: API_BASE
value: {{ .Values.frontend.env.API_BASE | quote }} value: "https://{{ .Values.backend.ingress.host }}"

View File

@ -5,8 +5,6 @@ frontend:
ingress: ingress:
enabled: true enabled: true
host: labmap.dvirlabs.com host: labmap.dvirlabs.com
env:
API_BASE: "https://api-labmap.dvirlabs.com"
backend: backend:
image: harbor.dvirlabs.com/my-apps/labmap-backend image: harbor.dvirlabs.com/my-apps/labmap-backend