upgrade architecture
This commit is contained in:
@@ -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",
|
||||
|
||||
262
frontend/src/components/architecture/ArchitectureCanvas.vue
Normal file
262
frontend/src/components/architecture/ArchitectureCanvas.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div class="h-full w-full vue-flow-monokai" ref="canvasWrapper">
|
||||
<VueFlow
|
||||
v-model:nodes="archStore.nodes"
|
||||
v-model:edges="archStore.edges"
|
||||
:node-types="nodeTypes"
|
||||
:connection-mode="ConnectionMode.Loose"
|
||||
:snap-to-grid="true"
|
||||
:snap-grid="[10, 10]"
|
||||
:default-edge-options="defaultEdgeOptions"
|
||||
:fit-view-on-init="true"
|
||||
:delete-key-code="['Delete', 'Backspace']"
|
||||
@node-click="onNodeClick"
|
||||
@edge-click="onEdgeClick"
|
||||
@pane-click="onPaneClick"
|
||||
@connect="onConnect"
|
||||
@nodes-delete="onNodesDelete"
|
||||
@edges-delete="onEdgesDelete"
|
||||
@node-drag-stop="onNodeDragStop"
|
||||
@drop="onDrop"
|
||||
@dragover.prevent="onDragOver"
|
||||
>
|
||||
<Background :color="'#75715E'" :gap="20" pattern-color="#75715E33" />
|
||||
<Controls position="top-right" />
|
||||
<MiniMap
|
||||
:node-color="miniMapNodeColor"
|
||||
:mask-color="'rgba(39, 40, 34, 0.8)'"
|
||||
position="bottom-right"
|
||||
/>
|
||||
</VueFlow>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, markRaw } from 'vue'
|
||||
import { VueFlow, useVueFlow, ConnectionMode } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
import '@vue-flow/controls/dist/style.css'
|
||||
import '@vue-flow/minimap/dist/style.css'
|
||||
import '@vue-flow/node-resizer/dist/style.css'
|
||||
|
||||
import ArchNode from './nodes/ArchNode.vue'
|
||||
import { useArchitectureStore } from '@/stores/architectureStore'
|
||||
|
||||
const archStore = useArchitectureStore()
|
||||
const canvasWrapper = ref(null)
|
||||
|
||||
const { project } = useVueFlow()
|
||||
|
||||
// Enregistrer le type de noeud custom
|
||||
const nodeTypes = {
|
||||
'arch-node': markRaw(ArchNode),
|
||||
}
|
||||
|
||||
// Options par défaut des edges (courbes bézier)
|
||||
const defaultEdgeOptions = {
|
||||
type: 'default',
|
||||
animated: false,
|
||||
style: { stroke: '#56A7FF', strokeWidth: 2 },
|
||||
}
|
||||
|
||||
// --- Événements ---
|
||||
|
||||
function onNodeClick({ node }) {
|
||||
archStore.selectNode(node.id)
|
||||
}
|
||||
|
||||
function onEdgeClick({ edge }) {
|
||||
archStore.selectEdge(edge.id)
|
||||
}
|
||||
|
||||
function onPaneClick() {
|
||||
archStore.clearSelection()
|
||||
}
|
||||
|
||||
function onConnect(connection) {
|
||||
archStore.addEdge(connection)
|
||||
}
|
||||
|
||||
function onNodesDelete(deletedNodes) {
|
||||
for (const node of deletedNodes) {
|
||||
if (node.data?.locked) continue
|
||||
archStore.removeNode(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
function onEdgesDelete(deletedEdges) {
|
||||
for (const edge of deletedEdges) {
|
||||
archStore.removeEdge(edge.id)
|
||||
}
|
||||
}
|
||||
|
||||
function onNodeDragStop({ node }) {
|
||||
// Mettre à jour la position dans le store
|
||||
const idx = archStore.nodes.findIndex(n => n.id === node.id)
|
||||
if (idx !== -1) {
|
||||
archStore.nodes[idx] = {
|
||||
...archStore.nodes[idx],
|
||||
position: { ...node.position }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag & Drop depuis la palette ---
|
||||
|
||||
function onDragOver(event) {
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la position absolue d'un noeud (en remontant la chaîne parentNode)
|
||||
*/
|
||||
function getAbsolutePosition(nodeId) {
|
||||
const node = archStore.nodes.find(n => n.id === nodeId)
|
||||
if (!node) return { x: 0, y: 0 }
|
||||
let x = node.position.x
|
||||
let y = node.position.y
|
||||
if (node.parentNode) {
|
||||
const parentPos = getAbsolutePosition(node.parentNode)
|
||||
x += parentPos.x
|
||||
y += parentPos.y
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve le noeud parent le plus spécifique (le plus petit) contenant la position absolue
|
||||
*/
|
||||
function findParentAtPosition(absPos) {
|
||||
let bestParent = null
|
||||
let bestArea = Infinity
|
||||
|
||||
for (const node of archStore.nodes) {
|
||||
const nodeAbsPos = getAbsolutePosition(node.id)
|
||||
const w = parseFloat(node.style?.width) || 120
|
||||
const h = parseFloat(node.style?.height) || 44
|
||||
|
||||
if (absPos.x >= nodeAbsPos.x && absPos.x <= nodeAbsPos.x + w &&
|
||||
absPos.y >= nodeAbsPos.y && absPos.y <= nodeAbsPos.y + h) {
|
||||
const area = w * h
|
||||
if (area < bestArea) {
|
||||
bestArea = area
|
||||
bestParent = node
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestParent
|
||||
}
|
||||
|
||||
function onDrop(event) {
|
||||
const toolId = event.dataTransfer.getData('application/archnode')
|
||||
if (!toolId) return
|
||||
|
||||
// Convertir la position écran en position du flow (absolue)
|
||||
const absPosition = project({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
})
|
||||
|
||||
// Détecter si on drop sur un noeud existant → nesting
|
||||
const parent = findParentAtPosition(absPosition)
|
||||
|
||||
if (parent) {
|
||||
// Position relative au parent
|
||||
const parentAbsPos = getAbsolutePosition(parent.id)
|
||||
const relativePosition = {
|
||||
x: absPosition.x - parentAbsPos.x,
|
||||
y: absPosition.y - parentAbsPos.y,
|
||||
}
|
||||
archStore.addNode(toolId, relativePosition, parent.id)
|
||||
} else {
|
||||
archStore.addNode(toolId, absPosition)
|
||||
}
|
||||
}
|
||||
|
||||
// --- MiniMap ---
|
||||
|
||||
function miniMapNodeColor(node) {
|
||||
if (node.data?.online) return '#A6E22E'
|
||||
const colors = {
|
||||
world: '#E6E6E6',
|
||||
home: '#8F6FE3',
|
||||
computer: '#B5B5B5',
|
||||
network: '#56A7FF',
|
||||
room: '#C8A08A',
|
||||
vm: '#62D36E',
|
||||
service: '#F0A33A',
|
||||
}
|
||||
return colors[node.data?.nodeType] || '#75715E'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Thème Monokai pour Vue Flow */
|
||||
.vue-flow-monokai {
|
||||
background-color: #272822;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__background {
|
||||
background-color: #272822;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__controls {
|
||||
background: #272822;
|
||||
border: 1px solid #75715E;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__controls-button {
|
||||
background: #272822;
|
||||
border-color: #75715E;
|
||||
color: #F8F8F2;
|
||||
fill: #F8F8F2;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__controls-button:hover {
|
||||
background: #3E3D32;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__controls-button svg {
|
||||
fill: #F8F8F2;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__minimap {
|
||||
background: #1E1F1C;
|
||||
border: 1px solid #75715E;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__edge-path {
|
||||
stroke: #56A7FF;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__edge.selected .vue-flow__edge-path {
|
||||
stroke: #66D9EF;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__connection-line {
|
||||
stroke: #A6E22E;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__handle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.vue-flow-monokai .vue-flow__selection {
|
||||
background: rgba(102, 217, 239, 0.1);
|
||||
border: 1px solid #66D9EF;
|
||||
}
|
||||
|
||||
/* Attribution panel cachée */
|
||||
.vue-flow-monokai .vue-flow__attribution {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
345
frontend/src/components/architecture/ArchitectureProperties.vue
Normal file
345
frontend/src/components/architecture/ArchitectureProperties.vue
Normal file
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<aside class="border border-monokai-comment/60 rounded-3xl p-5 overflow-auto h-full text-sm">
|
||||
|
||||
<!-- Propriétés d'un Edge sélectionné -->
|
||||
<div v-if="selectedEdge" class="space-y-3">
|
||||
<h3 class="text-monokai-cyan font-bold flex items-center gap-2">
|
||||
<span class="mdi mdi-vector-line"></span> Connexion
|
||||
</h3>
|
||||
|
||||
<details open>
|
||||
<summary class="text-monokai-comment cursor-pointer select-none">Style</summary>
|
||||
<div class="mt-2 space-y-2 pl-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-20">Couleur</span>
|
||||
<input type="color" :value="selectedEdge.data?.color || '#56A7FF'"
|
||||
@input="updateEdge('color', $event.target.value)" class="w-8 h-6 bg-transparent border-0 cursor-pointer" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-20">Épaisseur</span>
|
||||
<input type="number" min="1" max="10" :value="selectedEdge.data?.width || 2"
|
||||
@input="updateEdge('width', toNum($event.target.value))" class="input-field w-16" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-20">Trait</span>
|
||||
<select :value="selectedEdge.data?.dash || 'solid'" @change="updateEdge('dash', $event.target.value)" class="input-field">
|
||||
<option value="solid">plein</option>
|
||||
<option value="dashed">pointillé</option>
|
||||
<option value="dotted">points</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-20">Début</span>
|
||||
<select :value="selectedEdge.data?.startCap || 'none'" @change="updateEdge('startCap', $event.target.value)" class="input-field">
|
||||
<option value="none">aucune</option>
|
||||
<option value="arrow">flèche</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-20">Fin</span>
|
||||
<select :value="selectedEdge.data?.endCap || 'none'" @change="updateEdge('endCap', $event.target.value)" class="input-field">
|
||||
<option value="none">aucune</option>
|
||||
<option value="arrow">flèche</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<button @click="$emit('delete-edge', selectedEdge.id)"
|
||||
class="w-full mt-2 px-3 py-1.5 rounded text-xs bg-monokai-pink/20 text-monokai-pink hover:bg-monokai-pink/30 transition-colors">
|
||||
<span class="mdi mdi-delete"></span> Supprimer la connexion
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Propriétés d'un Noeud sélectionné -->
|
||||
<div v-else-if="selectedNode" class="space-y-3">
|
||||
<h3 class="text-monokai-cyan font-bold flex items-center gap-2">
|
||||
<span class="mdi mdi-square-edit-outline"></span> Propriétés
|
||||
</h3>
|
||||
|
||||
<!-- Section IP -->
|
||||
<details open>
|
||||
<summary class="text-monokai-comment cursor-pointer select-none">IP</summary>
|
||||
<div class="mt-2 space-y-2 pl-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<input :value="nodeData.ipAddress" @input="update('ipAddress', $event.target.value)"
|
||||
placeholder="ex: 10.0.0.5" class="input-field flex-1" />
|
||||
<button @click="$emit('sync-ip', selectedNode.id)"
|
||||
class="px-2 py-1 rounded text-xs bg-monokai-cyan/20 text-monokai-cyan hover:bg-monokai-cyan/30"
|
||||
title="Synchroniser les données IP">
|
||||
<span class="mdi mdi-sync"></span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea v-if="nodeData.ipData" readonly
|
||||
class="w-full bg-monokai-bg border border-monokai-comment/40 rounded px-2 py-1 text-xs font-mono h-24 resize-none text-monokai-comment"
|
||||
:value="formatIpData(nodeData.ipData)"></textarea>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Section Identité -->
|
||||
<details open>
|
||||
<summary class="text-monokai-comment cursor-pointer select-none">Identité</summary>
|
||||
<div class="mt-2 space-y-2 pl-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Nom</span>
|
||||
<input :value="nodeData.name" @input="update('name', $event.target.value)" class="input-field flex-1" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">ID</span>
|
||||
<input :value="nodeData.id" @input="update('id', $event.target.value)" class="input-field flex-1" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Label</span>
|
||||
<input :value="nodeData.label" @input="update('label', $event.target.value)" class="input-field flex-1" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Type</span>
|
||||
<input :value="nodeData.type" @input="update('type', $event.target.value)" class="input-field flex-1" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Icône</span>
|
||||
<input :value="nodeData.icon" @input="update('icon', $event.target.value)" class="input-field flex-1" />
|
||||
<button @click="$emit('pick-icon', selectedNode.id)"
|
||||
class="px-2 py-1 rounded text-xs bg-monokai-comment/20 text-monokai-text hover:bg-monokai-comment/30">
|
||||
choisir
|
||||
</button>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Taille</span>
|
||||
<input type="range" min="12" max="48" :value="nodeData.iconSize || 16"
|
||||
@input="update('iconSize', toNum($event.target.value))" class="flex-1" />
|
||||
<span class="text-monokai-comment text-xs w-8 text-right">{{ nodeData.iconSize || 16 }}px</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" :checked="nodeData.locked" @change="update('locked', $event.target.checked)"
|
||||
class="accent-monokai-pink" />
|
||||
<span class="text-monokai-comment">Verrouillé</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Section Relations -->
|
||||
<details>
|
||||
<summary class="text-monokai-comment cursor-pointer select-none">Relations</summary>
|
||||
<div class="mt-2 space-y-2 pl-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Parent</span>
|
||||
<input :value="nodeData.parentId" @input="update('parentId', $event.target.value)"
|
||||
class="input-field flex-1" placeholder="instanceId du parent" />
|
||||
</label>
|
||||
<div v-if="nodeData.childrenIds?.length" class="text-xs text-monokai-comment">
|
||||
Enfants : {{ nodeData.childrenIds.join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Section Géométrie -->
|
||||
<details open>
|
||||
<summary class="text-monokai-comment cursor-pointer select-none">Géométrie</summary>
|
||||
<div class="mt-2 space-y-2 pl-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="text-monokai-comment text-xs">X</span>
|
||||
<input type="number" :value="Math.round(selectedNode.position?.x || 0)"
|
||||
@input="update('position.x', toNum($event.target.value))" class="input-field w-full" />
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="text-monokai-comment text-xs">Y</span>
|
||||
<input type="number" :value="Math.round(selectedNode.position?.y || 0)"
|
||||
@input="update('position.y', toNum($event.target.value))" class="input-field w-full" />
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="text-monokai-comment text-xs">L</span>
|
||||
<input type="number" :value="parseSize(selectedNode.style?.width)"
|
||||
@input="update('width', toNum($event.target.value))" class="input-field w-full" />
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="text-monokai-comment text-xs">H</span>
|
||||
<input type="number" :value="parseSize(selectedNode.style?.height)"
|
||||
@input="update('height', toNum($event.target.value))" class="input-field w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">zIndex</span>
|
||||
<input type="number" :value="selectedNode.zIndex || 1"
|
||||
@input="update('zIndex', toNum($event.target.value))" class="input-field w-20" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Section Couleur -->
|
||||
<details open>
|
||||
<summary class="text-monokai-comment cursor-pointer select-none">Couleur</summary>
|
||||
<div class="mt-2 space-y-2 pl-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Fond</span>
|
||||
<input type="color" :value="nodeData.fillColor === 'transparent' ? '#272822' : nodeData.fillColor"
|
||||
@input="update('fillColor', $event.target.value)" class="w-8 h-6 bg-transparent border-0 cursor-pointer" />
|
||||
<button @click="update('fillColor', 'transparent')"
|
||||
class="text-xs text-monokai-comment hover:text-monokai-text">transparent</button>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Opacité</span>
|
||||
<input type="range" min="0" max="1" step="0.05" :value="nodeData.opacity ?? 1"
|
||||
@input="update('opacity', toNum($event.target.value))" class="flex-1" />
|
||||
<span class="text-monokai-comment text-xs w-8 text-right">{{ Math.round((nodeData.opacity ?? 1) * 100) }}%</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Section Trait -->
|
||||
<details>
|
||||
<summary class="text-monokai-comment cursor-pointer select-none">Trait</summary>
|
||||
<div class="mt-2 space-y-2 pl-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Couleur</span>
|
||||
<input type="color" :value="nodeData.borderColor || '#B5B5B5'"
|
||||
@input="update('borderColor', $event.target.value)" class="w-8 h-6 bg-transparent border-0 cursor-pointer" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Épaisseur</span>
|
||||
<input type="number" min="1" max="12" :value="nodeData.borderWidth || 2"
|
||||
@input="update('borderWidth', toNum($event.target.value))" class="input-field w-16" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Style</span>
|
||||
<select :value="nodeData.borderStyle || 'solid'" @change="update('borderStyle', $event.target.value)" class="input-field">
|
||||
<option value="solid">plein</option>
|
||||
<option value="dashed">pointillé</option>
|
||||
<option value="dotted">points</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Section Connecteurs (interfaces réseau) -->
|
||||
<details open>
|
||||
<summary class="text-monokai-comment cursor-pointer select-none">Connecteurs</summary>
|
||||
<div class="mt-2 space-y-2 pl-2">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="text-monokai-comment text-xs">Haut</span>
|
||||
<input type="number" min="0" max="8" :value="nodeData.connectTop || 0"
|
||||
@input="update('connectTop', toNum($event.target.value))" class="input-field w-full" />
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="text-monokai-comment text-xs">Bas</span>
|
||||
<input type="number" min="0" max="8" :value="nodeData.connectBottom || 0"
|
||||
@input="update('connectBottom', toNum($event.target.value))" class="input-field w-full" />
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="text-monokai-comment text-xs">Gauche</span>
|
||||
<input type="number" min="0" max="8" :value="nodeData.connectLeft || 0"
|
||||
@input="update('connectLeft', toNum($event.target.value))" class="input-field w-full" />
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<span class="text-monokai-comment text-xs">Droite</span>
|
||||
<input type="number" min="0" max="8" :value="nodeData.connectRight || 0"
|
||||
@input="update('connectRight', toNum($event.target.value))" class="input-field w-full" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Couleur</span>
|
||||
<input type="color" :value="nodeData.connectorColor || '#B5B5B5'"
|
||||
@input="update('connectorColor', $event.target.value)" class="w-8 h-6 bg-transparent border-0 cursor-pointer" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Taille</span>
|
||||
<input type="number" min="6" max="24" :value="nodeData.connectorSize || 12"
|
||||
@input="update('connectorSize', toNum($event.target.value))" class="input-field w-16" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Contour</span>
|
||||
<input type="color" :value="nodeData.connectorStrokeColor || '#1E1F1C'"
|
||||
@input="update('connectorStrokeColor', $event.target.value)" class="w-8 h-6 bg-transparent border-0 cursor-pointer" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Section Texte -->
|
||||
<details>
|
||||
<summary class="text-monokai-comment cursor-pointer select-none">Texte</summary>
|
||||
<div class="mt-2 space-y-2 pl-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Couleur</span>
|
||||
<input type="color" :value="nodeData.fontColor || '#E6E6E6'"
|
||||
@input="update('fontColor', $event.target.value)" class="w-8 h-6 bg-transparent border-0 cursor-pointer" />
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment w-16">Taille</span>
|
||||
<input type="number" min="8" max="48" :value="nodeData.fontSize || 14"
|
||||
@input="update('fontSize', toNum($event.target.value))" class="input-field w-16" />
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Bouton supprimer -->
|
||||
<button v-if="!nodeData.locked" @click="$emit('delete-node', selectedNode.id)"
|
||||
class="w-full mt-2 px-3 py-1.5 rounded text-xs bg-monokai-pink/20 text-monokai-pink hover:bg-monokai-pink/30 transition-colors">
|
||||
<span class="mdi mdi-delete"></span> Supprimer le noeud
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Aucune sélection -->
|
||||
<div v-else class="text-monokai-comment text-center">
|
||||
<span class="mdi mdi-cursor-default-click text-4xl block mb-3 opacity-30"></span>
|
||||
<p>Aucun objet sélectionné</p>
|
||||
<p class="text-xs mt-1 opacity-60">Cliquez sur un noeud ou une connexion</p>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useArchitectureStore } from '@/stores/architectureStore'
|
||||
|
||||
const archStore = useArchitectureStore()
|
||||
|
||||
defineEmits(['delete-node', 'delete-edge', 'sync-ip', 'pick-icon'])
|
||||
|
||||
const selectedNode = computed(() => archStore.selectedNode)
|
||||
const selectedEdge = computed(() => archStore.selectedEdge)
|
||||
const nodeData = computed(() => selectedNode.value?.data || {})
|
||||
|
||||
function update(field, value) {
|
||||
if (!selectedNode.value) return
|
||||
archStore.updateNodeData(selectedNode.value.id, field, value)
|
||||
}
|
||||
|
||||
function updateEdge(field, value) {
|
||||
if (!selectedEdge.value) return
|
||||
archStore.updateEdgeData(selectedEdge.value.id, field, value)
|
||||
}
|
||||
|
||||
function toNum(val) {
|
||||
const n = Number(val)
|
||||
return isNaN(n) ? 0 : n
|
||||
}
|
||||
|
||||
function parseSize(sizeStr) {
|
||||
if (!sizeStr) return 120
|
||||
return parseInt(String(sizeStr).replace('px', ''), 10) || 120
|
||||
}
|
||||
|
||||
function formatIpData(ipData) {
|
||||
if (!ipData) return ''
|
||||
const lines = []
|
||||
if (ipData.ip) lines.push(`IP: ${ipData.ip}`)
|
||||
if (ipData.name) lines.push(`Nom: ${ipData.name}`)
|
||||
if (ipData.mac) lines.push(`MAC: ${ipData.mac}`)
|
||||
if (ipData.vendor) lines.push(`Vendor: ${ipData.vendor}`)
|
||||
if (ipData.hostname) lines.push(`Hostname: ${ipData.hostname}`)
|
||||
if (ipData.last_status) lines.push(`Status: ${ipData.last_status}`)
|
||||
if (ipData.location) lines.push(`Location: ${ipData.location}`)
|
||||
if (ipData.host) lines.push(`Host: ${ipData.host}`)
|
||||
if (ipData.open_ports?.length) lines.push(`Ports: ${ipData.open_ports.join(', ')}`)
|
||||
if (ipData.link) lines.push(`Lien: ${ipData.link}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-field {
|
||||
@apply bg-monokai-bg border border-monokai-comment/40 rounded px-2 py-1 text-xs text-monokai-text focus:border-monokai-cyan focus:outline-none;
|
||||
}
|
||||
</style>
|
||||
38
frontend/src/components/architecture/ArchitectureToolbar.vue
Normal file
38
frontend/src/components/architecture/ArchitectureToolbar.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<!-- Boutons outils (7 types) -->
|
||||
<button
|
||||
v-for="tool in TOOL_DEFINITIONS"
|
||||
:key="tool.id"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(tool, $event)"
|
||||
class="px-3 py-1.5 rounded-xl border-2 text-sm cursor-grab active:cursor-grabbing hover:opacity-80 transition-opacity"
|
||||
:style="{ borderColor: tool.borderColor, color: tool.color }"
|
||||
:title="`Glisser pour ajouter : ${tool.label}`"
|
||||
>
|
||||
{{ tool.label }}
|
||||
</button>
|
||||
|
||||
<div class="h-6 w-px bg-monokai-comment mx-1"></div>
|
||||
|
||||
<!-- Bouton Sauvegarder -->
|
||||
<button
|
||||
@click="$emit('save')"
|
||||
class="px-3 py-1.5 rounded-xl bg-monokai-green text-monokai-bg text-sm font-bold hover:bg-monokai-cyan transition-colors"
|
||||
title="Sauvegarder le diagramme"
|
||||
>
|
||||
<span class="mdi mdi-content-save"></span> Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TOOL_DEFINITIONS } from '@/stores/architectureStore'
|
||||
|
||||
defineEmits(['save'])
|
||||
|
||||
function onDragStart(tool, event) {
|
||||
event.dataTransfer.setData('application/archnode', tool.id)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
</script>
|
||||
180
frontend/src/components/architecture/nodes/ArchNode.vue
Normal file
180
frontend/src/components/architecture/nodes/ArchNode.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div
|
||||
class="arch-node rounded-xl select-none relative overflow-visible"
|
||||
:class="[selected ? 'ring-2 ring-monokai-cyan/70 ring-offset-1 ring-offset-monokai-bg' : '']"
|
||||
:style="nodeStyle"
|
||||
@dblclick.stop="startEdit"
|
||||
>
|
||||
<!-- Redimensionnement -->
|
||||
<NodeResizer
|
||||
:min-width="40"
|
||||
:min-height="28"
|
||||
:is-visible="selected && !data.locked"
|
||||
:color="data.borderColor || '#B5B5B5'"
|
||||
/>
|
||||
|
||||
<!-- Contenu du noeud -->
|
||||
<div class="px-3 py-2 flex items-center gap-1.5 h-full min-w-0">
|
||||
<!-- Icône -->
|
||||
<span v-if="data.icon" class="flex-shrink-0 inline-flex items-center">
|
||||
<img
|
||||
v-if="isImageIcon"
|
||||
:src="iconUrl"
|
||||
:style="{ width: `${data.iconSize || 16}px`, height: `${data.iconSize || 16}px` }"
|
||||
class="object-contain"
|
||||
/>
|
||||
<span v-else :style="{ fontSize: `${data.iconSize || 16}px` }">{{ data.icon }}</span>
|
||||
</span>
|
||||
|
||||
<!-- Label (éditable ou lecture) -->
|
||||
<input
|
||||
v-if="isEditing"
|
||||
ref="editInput"
|
||||
v-model="editValue"
|
||||
class="bg-transparent border-b border-monokai-cyan text-monokai-text text-sm outline-none min-w-0 flex-1"
|
||||
@blur="commitEdit"
|
||||
@keydown.enter="commitEdit"
|
||||
@keydown.escape="cancelEdit"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="truncate flex-1 min-w-0"
|
||||
:style="{ color: data.fontColor || '#E6E6E6', fontSize: `${data.fontSize || 14}px` }"
|
||||
>
|
||||
{{ data.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Indicateur online -->
|
||||
<div
|
||||
v-if="data.online"
|
||||
class="absolute left-2 bottom-1 flex items-center gap-1 text-[9px] text-monokai-green"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-monokai-green inline-block"></span>
|
||||
online
|
||||
</div>
|
||||
|
||||
<!-- Indicateur verrouillé -->
|
||||
<div v-if="data.locked" class="absolute right-1 top-1 text-monokai-comment text-[10px]">
|
||||
<span class="mdi mdi-lock"></span>
|
||||
</div>
|
||||
|
||||
<!-- Handles dynamiques (interfaces réseau) -->
|
||||
<Handle
|
||||
v-for="handle in handles"
|
||||
:key="handle.id"
|
||||
:id="handle.id"
|
||||
type="source"
|
||||
:position="handle.position"
|
||||
:style="handle.style"
|
||||
class="!rounded-full !border"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, nextTick } from 'vue'
|
||||
import { Handle, Position } from '@vue-flow/core'
|
||||
import { NodeResizer } from '@vue-flow/node-resizer'
|
||||
import { applyFillOpacity } from '@/composables/useArchitectureMigration'
|
||||
|
||||
const props = defineProps({
|
||||
id: String,
|
||||
data: { type: Object, required: true },
|
||||
selected: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:data'])
|
||||
|
||||
// --- Édition inline du label ---
|
||||
const isEditing = ref(false)
|
||||
const editValue = ref('')
|
||||
const editInput = ref(null)
|
||||
|
||||
function startEdit() {
|
||||
if (props.data.locked) return
|
||||
isEditing.value = true
|
||||
editValue.value = props.data.label || ''
|
||||
nextTick(() => editInput.value?.focus())
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
if (!isEditing.value) return
|
||||
isEditing.value = false
|
||||
if (editValue.value !== props.data.label) {
|
||||
// Mise à jour via le store (le parent capture l'événement)
|
||||
props.data.label = editValue.value
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
// --- Icône ---
|
||||
const isImageIcon = computed(() => {
|
||||
const icon = props.data.icon || ''
|
||||
return icon.includes('.') && (icon.endsWith('.svg') || icon.endsWith('.png') || icon.endsWith('.jpg'))
|
||||
})
|
||||
|
||||
const iconUrl = computed(() => `/icons/${props.data.icon}`)
|
||||
|
||||
// --- Style du noeud ---
|
||||
const nodeStyle = computed(() => ({
|
||||
borderColor: props.data.borderColor || '#B5B5B5',
|
||||
borderWidth: `${props.data.borderWidth || 2}px`,
|
||||
borderStyle: props.data.borderStyle || 'solid',
|
||||
backgroundColor: applyFillOpacity(props.data.fillColor, props.data.opacity),
|
||||
color: props.data.color || '#E6E6E6',
|
||||
}))
|
||||
|
||||
// --- Handles dynamiques (connecteurs / interfaces réseau) ---
|
||||
const handles = computed(() => {
|
||||
const result = []
|
||||
const d = props.data
|
||||
const size = d.connectorSize || 12
|
||||
const bgColor = d.connectorColor || '#B5B5B5'
|
||||
const strokeColor = d.connectorStrokeColor || '#1E1F1C'
|
||||
|
||||
const baseStyle = {
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
backgroundColor: bgColor,
|
||||
borderColor: strokeColor,
|
||||
}
|
||||
|
||||
const addSide = (count, side, position) => {
|
||||
for (let i = 0; i < (count || 0); i++) {
|
||||
const fraction = ((i + 1) / ((count || 0) + 1)) * 100
|
||||
const posStyle = { ...baseStyle }
|
||||
|
||||
if (side === 'top' || side === 'bottom') {
|
||||
posStyle.left = `${fraction}%`
|
||||
posStyle.transform = 'translateX(-50%)'
|
||||
} else {
|
||||
posStyle.top = `${fraction}%`
|
||||
posStyle.transform = 'translateY(-50%)'
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: `${side}-${i}`,
|
||||
position,
|
||||
style: posStyle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
addSide(d.connectTop, 'top', Position.Top)
|
||||
addSide(d.connectBottom, 'bottom', Position.Bottom)
|
||||
addSide(d.connectLeft, 'left', Position.Left)
|
||||
addSide(d.connectRight, 'right', Position.Right)
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.arch-node {
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
246
frontend/src/composables/useArchitectureMigration.js
Normal file
246
frontend/src/composables/useArchitectureMigration.js
Normal file
@@ -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 }
|
||||
}
|
||||
359
frontend/src/stores/architectureStore.js
Normal file
359
frontend/src/stores/architectureStore.js
Normal file
@@ -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,
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user