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

@@ -7,4 +7,5 @@ integrer les option dans config.yml et accessible egalement dans parametre
- [ ] ajouter un bouton dans volet gauche pour ajouter le parametrage d'un equipement dans opensense mappage static. possibilite d'ajouter des mappages avec des ip differentes de la plage de dhcp dans opnsense ?
- [ ] ajout backup de la bdd dans parametre
- [ ] brainstorming ajout d'un onglet opnsense qui presente des parametrages claire des services actif et des paramaetrage disponible (style tableau de bord) avec des tooltips explicatif clair, une section logs et erreur
- [ ] intercale un bouton entre suivi et architecture nommé ports et service qui affiche une section de recherche d'ip en fonction d'un port ou d'un service. dans le volet gauche affiche une liste de service associé a son port dans la section centrale un bouton de scan. le scan effectuera une recherche pour chaqu ip si le service coché est actif ou pas et mettra a disposition un tableau de resultat clair et lisible avec un lien url clicable vers le service. dans le volet gauche un listing stocké dans config.yaml- tu generera deja un premier listing avec les services les plus connu avec leur port ( web, proxmox, arcane, ....)
- [x] intercale un bouton entre suivi et architecture nommé ports et service qui affiche une section de recherche d'ip en fonction d'un port ou d'un service. dans le volet gauche affiche une liste de service associé a son port dans la section centrale un bouton de scan. le scan effectuera une recherche pour chaqu ip si le service coché est actif ou pas et mettra a disposition un tableau de resultat clair et lisible avec un lien url clicable vers le service. dans le volet gauche un listing stocké dans config.yaml- tu generera deja un premier listing avec les services les plus connu avec leur port ( web, proxmox, arcane, ....)
- Onglet Ports & Services implémenté avec scan parallélisé (20 IPs simultanées), résultats en temps réel via WebSocket, tableau avec liens cliquables, 25+ services préconfigurés dans config.yaml

View File

@@ -45,8 +45,13 @@ class ArchitectureNodeResponse(BaseModel):
class ArchitectureWorldPayload(BaseModel):
items: List[Dict[str, Any]]
# Format v1 (legacy)
items: Optional[List[Dict[str, Any]]] = None
splines: Optional[List[Dict[str, Any]]] = None
# Format v2 (Vue Flow)
nodes: Optional[List[Dict[str, Any]]] = None
edges: Optional[List[Dict[str, Any]]] = None
version: Optional[int] = None
@router.get("/nodes", response_model=List[ArchitectureNodeResponse])
@@ -122,11 +127,25 @@ async def get_world():
@router.post("/world")
async def save_world(payload: ArchitectureWorldPayload):
"""Sauvegarde les éléments du world dans architecture.json."""
"""Sauvegarde les éléments du world dans architecture.json.
Accepte le format v1 (items/splines) ou v2 (nodes/edges)."""
ensure_world_file()
splines = payload.splines or []
WORLD_FILE.write_text(
json.dumps({"items": payload.items, "splines": splines}, indent=2),
encoding="utf-8"
)
return {"status": "ok", "count": len(payload.items), "splines": len(splines)}
if payload.version and payload.version >= 2:
# Format v2 (Vue Flow)
data = {
"nodes": payload.nodes or [],
"edges": payload.edges or [],
"version": payload.version,
}
WORLD_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
return {"status": "ok", "nodes": len(data["nodes"]), "edges": len(data["edges"])}
else:
# Format v1 (legacy)
items = payload.items or []
splines = payload.splines or []
WORLD_FILE.write_text(
json.dumps({"items": items, "splines": splines}, indent=2),
encoding="utf-8"
)
return {"status": "ok", "count": len(items), "splines": len(splines)}

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