Compare commits

..

60 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
a63a30e8d8 test the pipeline 2025-07-04 10:47:43 +03:00
d3b8bf39ad Update helm 2025-07-02 04:38:28 +03:00
0101a2d40c Update helm 2025-07-02 04:37:27 +03:00
73dffb2893 Merge pull request 'develop' (#6) from develop into master
Reviewed-on: #6
2025-07-01 23:36:39 +00:00
a8bf733609 Merge pull request 'Add toast alerts' (#5) from toast-alerts into develop
Reviewed-on: #5
2025-07-01 23:06:13 +00:00
940f680489 Add toast alerts 2025-07-02 02:04:33 +03:00
e01ae1dc92 Set all dots source and targets 2025-07-01 15:22:14 +03:00
23b72f3e41 Add more handels 2025-07-01 14:30:39 +03:00
337fb30ef0 Merge pull request 'develop' (#4) from develop into master
Reviewed-on: #4
2025-07-01 09:55:42 +00:00
603a0233f7 Change Selected icon paragraf to black color 2025-07-01 12:24:34 +03:00
f0b5cc0378 Change Choose icon label to black color 2025-07-01 12:20:23 +03:00
74db6008b0 Add env.js for develop 2025-07-01 12:16:27 +03:00
2c0e566fb8 Change entrypoint 2025-07-01 11:24:05 +03:00
81bbc15107 Change entrypoint 2025-07-01 11:20:30 +03:00
9c9fc2de67 Fix vite.connfig 2025-07-01 10:16:42 +03:00
53d0770ee7 Build back and front again 2025-07-01 10:04:59 +03:00
f4ed5999c2 Please work' 2025-07-01 09:41:53 +03:00
482eba3bd1 Fix back and front 2025-07-01 09:31:24 +03:00
42ebf07216 Fix env.js 2025-07-01 07:37:18 +03:00
3954963a40 Fix docker-entrypoint.sh 2025-07-01 07:26:43 +03:00
faa9aaa740 Fix docker-entrypoint.sh 2025-07-01 07:07:19 +03:00
9124e41ec2 Fix Dockerfile 2025-07-01 07:02:32 +03:00
0a0f9f37b9 Trigger 2025-07-01 06:54:50 +03:00
d7edb08c1e Fix index.html and api 2025-07-01 06:42:30 +03:00
5fa74043bc env template and Dockerfile 2025-07-01 06:29:35 +03:00
6bb73f0bc5 Try to build again and push 2025-07-01 06:10:38 +03:00
15004506ce Update tag and add env 2025-07-01 06:09:04 +03:00
66561567ae Update tag and add env 2025-07-01 06:08:24 +03:00
ffccf7a22e Try inject env 2025-07-01 05:59:39 +03:00
6af00d9f0d Try inject env 2025-07-01 05:56:13 +03:00
ff89b282ce Merge pull request 'develop' (#3) from develop into master
Reviewed-on: #3
2025-06-18 01:26:28 +00:00
769c04c399 Set the update values stage to set the tag branchname-commithash and fix the pipeline to not clone in update backend values stage 2025-06-18 04:20:44 +03:00
767b753f70 Set the update values stage to set the tag branchname-commithash 2025-06-18 04:19:00 +03:00
deb1fa13d3 Push to clean project after testing the pipeline 2025-06-18 04:11:51 +03:00
cbe41450f9 Merge pull request 'test-branch' (#2) from test-branch into master
Reviewed-on: #2
2025-06-17 23:31:16 +00:00
25 changed files with 849 additions and 490 deletions

View File

@ -9,19 +9,17 @@ steps:
include: [ frontend/** ] include: [ frontend/** ]
settings: settings:
registry: harbor.dvirlabs.com registry: harbor.dvirlabs.com
repo: my-apps/labmap-frontend repo: my-apps/${CI_REPO_NAME}-frontend
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
context: frontend context: frontend
tags: tags:
- latest - latest
- ${CI_COMMIT_SHA:0:7} - ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${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
image: woodpeckerci/plugin-kaniko image: woodpeckerci/plugin-kaniko
@ -32,13 +30,12 @@ steps:
include: [ backend/** ] include: [ backend/** ]
settings: settings:
registry: harbor.dvirlabs.com registry: harbor.dvirlabs.com
repo: my-apps/labmap-backend repo: my-apps/${CI_REPO_NAME}-backend
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
context: backend context: backend
tags: tags:
- latest - latest
- ${CI_COMMIT_SHA:0:7} - ${CI_COMMIT_TAG:-${CI_COMMIT_BRANCH}-${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:
@ -64,16 +61,12 @@ 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
- | - |
if [ "$CI_COMMIT_BRANCH" = "develop" ]; then TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
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/labmap/values.yaml yq -i ".frontend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
git add manifests/labmap/values.yaml git add manifests/${CI_REPO_NAME}/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 master git push origin HEAD
update-values-backend: update-values-backend:
name: Update backend tag in values.yaml name: Update backend tag in values.yaml
@ -92,16 +85,29 @@ 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
- | - |
if [ "$CI_COMMIT_BRANCH" = "develop" ]; then TAG="${CI_COMMIT_BRANCH}-${CI_COMMIT_SHA:0:7}"
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/labmap/values.yaml yq -i ".backend.tag = \"$TAG\"" manifests/${CI_REPO_NAME}/values.yaml
git add manifests/labmap/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 master 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,57 +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_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(): 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")
@ -80,6 +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,36 +0,0 @@
{
"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,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Custom
public/env.js

View File

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

View File

@ -3,7 +3,6 @@ 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
@ -16,15 +15,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 nginx config # ✅ Copy env.js.template
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
# Copy runtime env template + entrypoint # ✅ Add env generator script to nginx entrypoint hook
COPY public/env.js.template /usr/share/nginx/html/env.js.template COPY 10-generate-env.sh /docker-entrypoint.d/10-generate-env.sh
COPY docker-entrypoint.sh /entrypoint.sh RUN dos2unix /docker-entrypoint.d/10-generate-env.sh && chmod +x /docker-entrypoint.d/10-generate-env.sh
# Normalize line endings and set permissions
RUN dos2unix /entrypoint.sh && chmod +x /entrypoint.sh
EXPOSE 80 EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"] CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,9 +0,0 @@
#!/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="/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="%PUBLIC_URL%/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,8 +10,11 @@
"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

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -1,7 +1,14 @@
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 <Diagram />; return (
<>
<Diagram />
<ToastContainer position="bottom-right" autoClose={3000} />
</>
);
} }
export default App; export default App;

View File

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 271 B

View File

@ -3,31 +3,61 @@ 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: 80, width: 100,
height: 40, 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',
}}> }}>
{/* Handles - חובה */} {/* === TOP === */}
<Handle type="target" position={Position.Top} /> <Handle id="source-top" type="source" position={Position.Top} style={{ ...visibleHandleStyle, top: -6 }} />
<Handle type="source" position={Position.Bottom} /> <Handle id="target-top" type="target" position={Position.Top} style={{ ...invisibleHandleStyle, top: -6 }} />
{/* === 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, marginBottom: 6 }} style={{ width: 40, height: 40, marginTop: 6 }}
/> />
<div style={{ fontWeight: 'bold' }}>{data.label}</div> <div style={{ fontWeight: 'bold', fontSize: 10 }}>{data.label}</div>
</div> </div>
); );
} }

View File

@ -1,4 +1,3 @@
// src/components/Diagram.jsx
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import ReactFlow, { import ReactFlow, {
addEdge, addEdge,
@ -8,48 +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 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 () => {
try {
const { diagrams } = await listDiagrams();
setDiagramList(diagrams);
if (diagrams.length > 0) {
setDiagramName(diagrams[0]);
} else {
setDiagramName("default"); // <- ensure we use this name
}
} catch (err) {
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 || []); setNodes(data.nodes || []);
const sanitizedEdges = (data.edges || []).map(({ id, source, target, animated }) => ({ const sanitizedEdges = (data.edges || []).map(({ id, source, target, animated }) => ({
id, id, source, target, animated: !!animated
source,
target,
animated: !!animated,
})); }));
setEdges(sanitizedEdges); setEdges(sanitizedEdges);
}); setSelectedNode(null);
}, [setNodes, setEdges]); 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 });
alert('Diagram saved!'); toast.success(`✅ Diagram "${diagramName}" saved!`);
};
const handleAddNode = () => {
setShowForm(true);
}; };
const handleSubmitNode = () => { const handleSubmitNode = () => {
@ -59,21 +101,24 @@ 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 },
}; };
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);
}; };
@ -82,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) =>
@ -90,76 +149,149 @@ 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 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>
)} )}
@ -172,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' }}>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 }}> <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,20 +1,39 @@
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

@ -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;
}

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://vite.dev/config/ // https://vitejs.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: "https://{{ .Values.backend.ingress.host }}" value: {{ .Values.frontend.env.API_BASE | quote }}

View File

@ -5,6 +5,8 @@ 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