upgrade architecture

This commit is contained in:
2026-02-07 19:33:05 +01:00
parent 4eb3defa59
commit 497ab1550b
10 changed files with 1525 additions and 1454 deletions

View File

@@ -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",

View 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>

View 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>

View 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>

View 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>

View 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 }
}

View 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