diff --git a/amelioration.md b/amelioration.md index 6bca87c..4df4e30 100644 --- a/amelioration.md +++ b/amelioration.md @@ -7,4 +7,5 @@ integrer les option dans config.yml et accessible egalement dans parametre - [ ] ajouter un bouton dans volet gauche pour ajouter le parametrage d'un equipement dans opensense mappage static. possibilite d'ajouter des mappages avec des ip differentes de la plage de dhcp dans opnsense ? - [ ] ajout backup de la bdd dans parametre - [ ] brainstorming ajout d'un onglet opnsense qui presente des parametrages claire des services actif et des paramaetrage disponible (style tableau de bord) avec des tooltips explicatif clair, une section logs et erreur -- [ ] intercale un bouton entre suivi et architecture nommé ports et service qui affiche une section de recherche d'ip en fonction d'un port ou d'un service. dans le volet gauche affiche une liste de service associé a son port dans la section centrale un bouton de scan. le scan effectuera une recherche pour chaqu ip si le service coché est actif ou pas et mettra a disposition un tableau de resultat clair et lisible avec un lien url clicable vers le service. dans le volet gauche un listing stocké dans config.yaml- tu generera deja un premier listing avec les services les plus connu avec leur port ( web, proxmox, arcane, ....) \ No newline at end of file +- [x] intercale un bouton entre suivi et architecture nommé ports et service qui affiche une section de recherche d'ip en fonction d'un port ou d'un service. dans le volet gauche affiche une liste de service associé a son port dans la section centrale un bouton de scan. le scan effectuera une recherche pour chaqu ip si le service coché est actif ou pas et mettra a disposition un tableau de resultat clair et lisible avec un lien url clicable vers le service. dans le volet gauche un listing stocké dans config.yaml- tu generera deja un premier listing avec les services les plus connu avec leur port ( web, proxmox, arcane, ....) + - Onglet Ports & Services implémenté avec scan parallélisé (20 IPs simultanées), résultats en temps réel via WebSocket, tableau avec liens cliquables, 25+ services préconfigurés dans config.yaml \ No newline at end of file diff --git a/backend/app/routers/architecture.py b/backend/app/routers/architecture.py index 7ca09ba..25e52b6 100644 --- a/backend/app/routers/architecture.py +++ b/backend/app/routers/architecture.py @@ -45,8 +45,13 @@ class ArchitectureNodeResponse(BaseModel): class ArchitectureWorldPayload(BaseModel): - items: List[Dict[str, Any]] + # Format v1 (legacy) + items: Optional[List[Dict[str, Any]]] = None splines: Optional[List[Dict[str, Any]]] = None + # Format v2 (Vue Flow) + nodes: Optional[List[Dict[str, Any]]] = None + edges: Optional[List[Dict[str, Any]]] = None + version: Optional[int] = None @router.get("/nodes", response_model=List[ArchitectureNodeResponse]) @@ -122,11 +127,25 @@ async def get_world(): @router.post("/world") async def save_world(payload: ArchitectureWorldPayload): - """Sauvegarde les éléments du world dans architecture.json.""" + """Sauvegarde les éléments du world dans architecture.json. + Accepte le format v1 (items/splines) ou v2 (nodes/edges).""" ensure_world_file() - splines = payload.splines or [] - WORLD_FILE.write_text( - json.dumps({"items": payload.items, "splines": splines}, indent=2), - encoding="utf-8" - ) - return {"status": "ok", "count": len(payload.items), "splines": len(splines)} + + if payload.version and payload.version >= 2: + # Format v2 (Vue Flow) + data = { + "nodes": payload.nodes or [], + "edges": payload.edges or [], + "version": payload.version, + } + WORLD_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8") + return {"status": "ok", "nodes": len(data["nodes"]), "edges": len(data["edges"])} + else: + # Format v1 (legacy) + items = payload.items or [] + splines = payload.splines or [] + WORLD_FILE.write_text( + json.dumps({"items": items, "splines": splines}, indent=2), + encoding="utf-8" + ) + return {"status": "ok", "count": len(items), "splines": len(splines)} diff --git a/frontend/package.json b/frontend/package.json index dd55a47..30aa6e7 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,11 @@ }, "dependencies": { "@mdi/font": "^7.4.47", + "@vue-flow/background": "^1.3.0", + "@vue-flow/controls": "^1.1.0", + "@vue-flow/core": "^1.41.0", + "@vue-flow/minimap": "^1.5.0", + "@vue-flow/node-resizer": "^1.4.0", "axios": "^1.6.5", "pinia": "^2.1.7", "vue": "^3.4.15", diff --git a/frontend/src/components/architecture/ArchitectureCanvas.vue b/frontend/src/components/architecture/ArchitectureCanvas.vue new file mode 100644 index 0000000..b8ea39c --- /dev/null +++ b/frontend/src/components/architecture/ArchitectureCanvas.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/frontend/src/components/architecture/ArchitectureProperties.vue b/frontend/src/components/architecture/ArchitectureProperties.vue new file mode 100644 index 0000000..705e6ef --- /dev/null +++ b/frontend/src/components/architecture/ArchitectureProperties.vue @@ -0,0 +1,345 @@ + + + + + diff --git a/frontend/src/components/architecture/ArchitectureToolbar.vue b/frontend/src/components/architecture/ArchitectureToolbar.vue new file mode 100644 index 0000000..ef5a31b --- /dev/null +++ b/frontend/src/components/architecture/ArchitectureToolbar.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/components/architecture/nodes/ArchNode.vue b/frontend/src/components/architecture/nodes/ArchNode.vue new file mode 100644 index 0000000..7546dee --- /dev/null +++ b/frontend/src/components/architecture/nodes/ArchNode.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/frontend/src/composables/useArchitectureMigration.js b/frontend/src/composables/useArchitectureMigration.js new file mode 100644 index 0000000..8434eb2 --- /dev/null +++ b/frontend/src/composables/useArchitectureMigration.js @@ -0,0 +1,246 @@ +/** + * Composable de migration des données architecture + * Convertit le format v1 (items/splines) vers le format v2 (nodes/edges Vue Flow) + */ +import { MarkerType } from '@vue-flow/core' + +const KNOWN_PREFIXES = ['world', 'home', 'computer', 'network', 'room', 'vm', 'service'] + +/** + * Détecte le format des données + */ +export function detectFormat(data) { + if (!data) return 'empty' + if (data.version === 2 || data.nodes) return 'v2' + if (data.items) return 'v1' + return 'empty' +} + +/** + * Extrait le type de noeud depuis l'instanceId (ex: "computer-1766718222451-7tyo" → "computer") + */ +function extractNodeType(item) { + const prefix = (item.instanceId || '').split('-')[0] + if (KNOWN_PREFIXES.includes(prefix)) return prefix + + // Fallback par type sémantique + if (item.type === 'container') { + if (item.id === 'internet' || item.id === 'world') return 'world' + if (item.id === 'room') return 'room' + return 'home' + } + if (item.type === 'device') return 'computer' + if (item.type === 'network') return 'network' + if (item.type === 'vm') return 'vm' + if (item.type === 'service') return 'service' + return 'computer' +} + +/** + * Applique l'opacité sur une couleur hex/rgb + */ +export function applyFillOpacity(color, opacity) { + if (!color || color === 'transparent') return 'transparent' + const alpha = typeof opacity === 'number' ? opacity : 1 + if (color.startsWith('#')) { + let hex = color.slice(1) + if (hex.length === 3) hex = hex.split('').map(c => c + c).join('') + hex = hex.padEnd(6, '0') + const r = parseInt(hex.slice(0, 2), 16) + const g = parseInt(hex.slice(2, 4), 16) + const b = parseInt(hex.slice(4, 6), 16) + return `rgba(${r}, ${g}, ${b}, ${alpha})` + } + if (color.startsWith('rgb(')) { + const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/) + if (match) return `rgba(${match[1]}, ${match[2]}, ${match[3]}, ${alpha})` + } + return color +} + +/** + * Convertit un dash style en strokeDasharray CSS + */ +export function dashToStrokeDasharray(dash) { + if (dash === 'dashed') return '6 4' + if (dash === 'dotted') return '2 4' + return '0' +} + +/** + * Convertit un cap (startCap/endCap) en marker Vue Flow + */ +function capToMarker(cap, color) { + if (cap === 'arrow') return { type: MarkerType.ArrowClosed, color: color || '#56A7FF' } + return undefined +} + +/** + * Convertit un item legacy en noeud Vue Flow + */ +export function itemToNode(item) { + return { + id: item.instanceId, + type: 'arch-node', + position: { x: item.x || 0, y: item.y || 0 }, + style: { + width: `${item.width || 120}px`, + height: `${item.height || 44}px`, + }, + zIndex: item.zIndex || 1, + draggable: !item.locked, + data: { + // Type de la palette + nodeType: extractNodeType(item), + // Identité + instanceId: item.instanceId, + id: item.id || '', + name: item.name || '', + label: item.label || '', + type: item.type || '', + icon: item.icon || '', + iconSize: item.iconSize || 16, + // Couleurs + borderColor: item.borderColor || '#B5B5B5', + fillColor: item.fillColor || 'transparent', + color: item.color || '#E6E6E6', + opacity: typeof item.opacity === 'number' ? item.opacity : 1, + borderWidth: item.borderWidth || 2, + borderStyle: item.borderStyle || 'solid', + fontColor: item.fontColor || '#E6E6E6', + fontSize: item.fontSize || 14, + // Connecteurs (interfaces réseau) + connectTop: item.connectTop || 0, + connectBottom: item.connectBottom || 0, + connectLeft: item.connectLeft || 0, + connectRight: item.connectRight || 0, + connectorColor: item.connectorColor || '#B5B5B5', + connectorSize: item.connectorSize || 12, + connectorStrokeColor: item.connectorStrokeColor || '#1E1F1C', + // IP et réseau + ipAddress: item.ipAddress || '', + ipData: item.ipData || null, + online: item.online || false, + // Relations + parentId: item.parentId || '', + childrenIds: item.childrenIds || [], + parent: item.parent || '', + room: item.room || '', + // État + locked: item.locked || false, + } + } +} + +/** + * Convertit une spline legacy en edge Vue Flow + */ +export function splineToEdge(spline) { + const color = spline.style?.color || '#56A7FF' + const width = spline.style?.width || 2 + const dash = spline.style?.dash || 'solid' + + const style = { + stroke: color, + strokeWidth: width, + } + if (dash !== 'solid') { + style.strokeDasharray = dashToStrokeDasharray(dash) + } + + return { + id: spline.id, + source: spline.from?.nodeId, + target: spline.to?.nodeId, + sourceHandle: `${spline.from?.side || 'right'}-${spline.from?.index || 0}`, + targetHandle: `${spline.to?.side || 'left'}-${spline.to?.index || 0}`, + type: 'default', + style, + markerStart: capToMarker(spline.style?.startCap, color), + markerEnd: capToMarker(spline.style?.endCap, color), + data: { + color, + width, + dash, + startCap: spline.style?.startCap || 'none', + endCap: spline.style?.endCap || 'none', + } + } +} + +/** + * Convertit un noeud Vue Flow en format de sauvegarde + */ +export function nodeToSaveFormat(node) { + const result = { + id: node.id, + type: node.type, + position: { ...node.position }, + style: node.style ? { ...node.style } : {}, + zIndex: node.zIndex || 1, + draggable: node.draggable !== false, + data: { ...node.data }, + } + // Nesting : sauvegarder les propriétés parentNode/extent + if (node.parentNode) { + result.parentNode = node.parentNode + result.extent = 'parent' + result.expandParent = true + } + return result +} + +/** + * Convertit un edge Vue Flow en format de sauvegarde + */ +export function edgeToSaveFormat(edge) { + return { + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: edge.type || 'default', + style: edge.style ? { ...edge.style } : {}, + markerStart: edge.markerStart, + markerEnd: edge.markerEnd, + data: edge.data ? { ...edge.data } : {}, + } +} + +/** + * Point d'entrée : charge et migre les données si nécessaire + */ +export function migrateData(rawData) { + const format = detectFormat(rawData) + + if (format === 'empty') { + return { nodes: [], edges: [], version: 2 } + } + + if (format === 'v2') { + return { + nodes: rawData.nodes || [], + edges: rawData.edges || [], + version: 2, + } + } + + // Format v1 → convertir + const nodes = (rawData.items || []).map(itemToNode) + const edges = (rawData.splines || []).map(splineToEdge) + + // Restaurer les relations parentNode depuis parentId + const nodeIds = new Set(nodes.map(n => n.id)) + for (const node of nodes) { + if (node.data.parentId && nodeIds.has(node.data.parentId)) { + node.parentNode = node.data.parentId + node.extent = 'parent' + node.expandParent = true + } + } + + console.log(`[Architecture] Migration v1→v2 : ${nodes.length} nodes, ${edges.length} edges`) + + return { nodes, edges, version: 2 } +} diff --git a/frontend/src/stores/architectureStore.js b/frontend/src/stores/architectureStore.js new file mode 100644 index 0000000..eaff1e3 --- /dev/null +++ b/frontend/src/stores/architectureStore.js @@ -0,0 +1,359 @@ +/** + * Store Pinia pour l'éditeur d'architecture Vue Flow + * Gère les noeuds, edges, sélection, sauvegarde/chargement + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import axios from 'axios' +import { + migrateData, + nodeToSaveFormat, + edgeToSaveFormat, +} from '@/composables/useArchitectureMigration' + +/** Définition des outils de la palette (6 types, world supprimé) */ +export const TOOL_DEFINITIONS = [ + { id: 'home', label: 'home', type: 'container', icon: '', borderColor: '#8F6FE3', color: '#BDA8F7' }, + { id: 'computer', label: 'computer', type: 'device', icon: '', borderColor: '#B5B5B5', color: '#E6E6E6' }, + { id: 'network', label: 'network', type: 'network', icon: '🌐', borderColor: '#56A7FF', color: '#9BC8FF' }, + { id: 'room', label: 'room', type: 'container', icon: '', borderColor: '#C8A08A', color: '#E6CDBE' }, + { id: 'vm', label: 'vm', type: 'vm', icon: '', borderColor: '#62D36E', color: '#9BE3A4' }, + { id: 'service', label: 'service', type: 'service', icon: '', borderColor: '#F0A33A', color: '#F7C37B' }, +] + +/** Valeurs par défaut d'un edge */ +const DEFAULT_EDGE_STYLE = { + color: '#56A7FF', + width: 2, + dash: 'solid', + startCap: 'none', + endCap: 'none', +} + +export const useArchitectureStore = defineStore('architecture', () => { + // --- État --- + const nodes = ref([]) + const edges = ref([]) + const selectedNodeId = ref(null) + const selectedEdgeId = ref(null) + const iconList = ref([]) + + // --- Computed --- + const selectedNode = computed(() => { + if (!selectedNodeId.value) return null + return nodes.value.find(n => n.id === selectedNodeId.value) || null + }) + + const selectedEdge = computed(() => { + if (!selectedEdgeId.value) return null + return edges.value.find(e => e.id === selectedEdgeId.value) || null + }) + + // --- Chargement / Sauvegarde --- + + async function loadWorld() { + try { + const response = await axios.get('/api/architecture/world') + const migrated = migrateData(response.data) + nodes.value = migrated.nodes + edges.value = migrated.edges + console.log(`[Architecture] Chargé : ${nodes.value.length} nodes, ${edges.value.length} edges`) + } catch (error) { + console.error('[Architecture] Erreur chargement:', error) + nodes.value = [] + edges.value = [] + } + } + + async function saveWorld() { + try { + const data = { + nodes: nodes.value.map(nodeToSaveFormat), + edges: edges.value.map(edgeToSaveFormat), + version: 2, + } + await axios.post('/api/architecture/world', data) + console.log(`[Architecture] Sauvegardé : ${data.nodes.length} nodes, ${data.edges.length} edges`) + } catch (error) { + console.error('[Architecture] Erreur sauvegarde:', error) + } + } + + // --- CRUD Nodes --- + + function addNode(toolId, position, parentNodeId = null) { + const tool = TOOL_DEFINITIONS.find(t => t.id === toolId) + if (!tool) return null + + const ts = Date.now() + const rnd = Math.random().toString(36).slice(2, 6) + const instanceId = `${tool.id}-${ts}-${rnd}` + + const newNode = { + id: instanceId, + type: 'arch-node', + position: { x: Math.round(position.x), y: Math.round(position.y) }, + style: { width: '120px', height: '44px' }, + zIndex: 1, + draggable: true, + // Nesting : si parentNodeId, le noeud est contraint dans son parent + ...(parentNodeId ? { parentNode: parentNodeId, extent: 'parent', expandParent: true } : {}), + data: { + nodeType: tool.id, + instanceId, + id: tool.id, + name: tool.label, + label: tool.label, + type: tool.type, + icon: tool.icon, + iconSize: 16, + borderColor: tool.borderColor, + fillColor: 'transparent', + color: tool.color, + opacity: 1, + borderWidth: 2, + borderStyle: 'solid', + fontColor: '#E6E6E6', + fontSize: 14, + connectTop: 0, + connectBottom: 0, + connectLeft: 0, + connectRight: 0, + connectorColor: '#B5B5B5', + connectorSize: 12, + connectorStrokeColor: '#1E1F1C', + ipAddress: '', + ipData: null, + online: false, + parentId: parentNodeId || '', + childrenIds: [], + parent: '', + room: '', + locked: false, + } + } + + // Ajouter l'enfant dans la liste childrenIds du parent + if (parentNodeId) { + const parentIdx = nodes.value.findIndex(n => n.id === parentNodeId) + if (parentIdx !== -1) { + const parentNode = nodes.value[parentIdx] + const childrenIds = [...(parentNode.data?.childrenIds || []), instanceId] + nodes.value[parentIdx] = { + ...parentNode, + data: { ...parentNode.data, childrenIds } + } + } + } + + nodes.value = [...nodes.value, newNode] + selectedNodeId.value = instanceId + selectedEdgeId.value = null + return newNode + } + + function updateNodeData(nodeId, field, value) { + const idx = nodes.value.findIndex(n => n.id === nodeId) + if (idx === -1) return + const node = nodes.value[idx] + if (field !== 'locked' && node.data?.locked) return + + // Champs spéciaux qui vivent hors de data + if (field === 'position.x' || field === 'position.y') { + const axis = field.split('.')[1] + nodes.value[idx] = { + ...node, + position: { ...node.position, [axis]: Number(value) } + } + return + } + if (field === 'width' || field === 'height') { + nodes.value[idx] = { + ...node, + style: { ...node.style, [field]: `${value}px` } + } + return + } + if (field === 'zIndex') { + nodes.value[idx] = { ...node, zIndex: Number(value) } + return + } + if (field === 'locked') { + nodes.value[idx] = { + ...node, + draggable: !value, + data: { ...node.data, locked: value } + } + return + } + + // Champ standard dans data + nodes.value[idx] = { + ...node, + data: { ...node.data, [field]: value } + } + } + + function removeNode(nodeId) { + // Supprimer les edges connectés + edges.value = edges.value.filter(e => e.source !== nodeId && e.target !== nodeId) + // Détacher les enfants du noeud supprimé (les rendre top-level) + nodes.value = nodes.value.map(n => { + if (n.parentNode === nodeId) { + const { parentNode, extent, expandParent, ...rest } = n + return { ...rest, data: { ...n.data, parentId: '' } } + } + if (n.data?.parentId === nodeId) { + return { ...n, data: { ...n.data, parentId: '' } } + } + if (Array.isArray(n.data?.childrenIds) && n.data.childrenIds.includes(nodeId)) { + return { ...n, data: { ...n.data, childrenIds: n.data.childrenIds.filter(id => id !== nodeId) } } + } + return n + }) + nodes.value = nodes.value.filter(n => n.id !== nodeId) + if (selectedNodeId.value === nodeId) selectedNodeId.value = null + } + + // --- CRUD Edges --- + + function addEdge(connection) { + const id = `edge-${Date.now()}-${Math.random().toString(36).slice(2, 6)}` + const newEdge = { + id, + source: connection.source, + target: connection.target, + sourceHandle: connection.sourceHandle, + targetHandle: connection.targetHandle, + type: 'default', + style: { + stroke: DEFAULT_EDGE_STYLE.color, + strokeWidth: DEFAULT_EDGE_STYLE.width, + }, + data: { ...DEFAULT_EDGE_STYLE }, + } + edges.value = [...edges.value, newEdge] + return newEdge + } + + function updateEdgeData(edgeId, field, value) { + const idx = edges.value.findIndex(e => e.id === edgeId) + if (idx === -1) return + + const edge = edges.value[idx] + const newData = { ...edge.data, [field]: value } + + // Mettre à jour le style SVG en conséquence + const newStyle = { + stroke: newData.color || '#56A7FF', + strokeWidth: newData.width || 2, + } + if (newData.dash && newData.dash !== 'solid') { + newStyle.strokeDasharray = newData.dash === 'dashed' ? '6 4' : '2 4' + } + + edges.value[idx] = { + ...edge, + style: newStyle, + data: newData, + } + } + + function removeEdge(edgeId) { + edges.value = edges.value.filter(e => e.id !== edgeId) + if (selectedEdgeId.value === edgeId) selectedEdgeId.value = null + } + + // --- Sélection --- + + function selectNode(nodeId) { + selectedNodeId.value = nodeId + selectedEdgeId.value = null + } + + function selectEdge(edgeId) { + selectedEdgeId.value = edgeId + selectedNodeId.value = null + } + + function clearSelection() { + selectedNodeId.value = null + selectedEdgeId.value = null + } + + // --- IP Integration --- + + async function fetchIpData(nodeId) { + const node = nodes.value.find(n => n.id === nodeId) + if (!node || !node.data?.ipAddress) return + + try { + const response = await axios.get(`/api/ips/${encodeURIComponent(node.data.ipAddress)}`) + const ipData = response.data + + const updates = { + ipData, + name: ipData.name || node.data.name, + label: ipData.name || node.data.label, + id: ipData.ip || node.data.id, + type: ipData.vm ? 'vm' : node.data.type, + room: ipData.location || node.data.room, + parent: ipData.host || node.data.parent, + icon: ipData.icon_filename || node.data.icon, + online: ipData.last_status === 'online', + } + + const idx = nodes.value.findIndex(n => n.id === nodeId) + if (idx !== -1) { + nodes.value[idx] = { + ...nodes.value[idx], + data: { ...nodes.value[idx].data, ...updates } + } + } + } catch (error) { + console.error(`[Architecture] Erreur fetch IP ${node.data.ipAddress}:`, error) + updateNodeData(nodeId, 'ipData', null) + } + } + + // --- Icônes --- + + async function fetchIcons() { + try { + const response = await axios.get('/api/ips/icons') + iconList.value = Array.isArray(response.data?.icons) ? response.data.icons : [] + } catch { + iconList.value = [] + } + } + + return { + // État + nodes, + edges, + selectedNodeId, + selectedEdgeId, + iconList, + // Computed + selectedNode, + selectedEdge, + // Chargement + loadWorld, + saveWorld, + // Nodes + addNode, + updateNodeData, + removeNode, + // Edges + addEdge, + updateEdgeData, + removeEdge, + // Sélection + selectNode, + selectEdge, + clearSelection, + // IP + fetchIpData, + fetchIcons, + } +}) diff --git a/frontend/src/views/ArchitectureView.vue b/frontend/src/views/ArchitectureView.vue index be884e6..059e024 100644 --- a/frontend/src/views/ArchitectureView.vue +++ b/frontend/src/views/ArchitectureView.vue @@ -7,15 +7,12 @@

IPWatch

Architecture réseau - bêta
-
- -
+
+
-
-
-
-
- -
- +
+
+
+
-
- - - - - - - - - - - - - -
-
- - online -
- - - -
+
+
- +
-
-
- {{ dragItem.label }} -
-
- +
Icônes (data/icons)
-
+
@@ -622,50 +82,7 @@
-
-
-
Sélection du parent
-
- Plusieurs objets correspondent à {{ parentLinkDialog.hostName }}. - Choisis le parent à lier. -
-
- -
-
- - -
-
-
- +