upgrade architecture
This commit is contained in:
@@ -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 ?
|
- [ ] 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
|
- [ ] 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
|
- [ ] 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
|
||||||
@@ -45,8 +45,13 @@ class ArchitectureNodeResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class ArchitectureWorldPayload(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
|
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])
|
@router.get("/nodes", response_model=List[ArchitectureNodeResponse])
|
||||||
@@ -122,11 +127,25 @@ async def get_world():
|
|||||||
|
|
||||||
@router.post("/world")
|
@router.post("/world")
|
||||||
async def save_world(payload: ArchitectureWorldPayload):
|
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()
|
ensure_world_file()
|
||||||
|
|
||||||
|
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 []
|
splines = payload.splines or []
|
||||||
WORLD_FILE.write_text(
|
WORLD_FILE.write_text(
|
||||||
json.dumps({"items": payload.items, "splines": splines}, indent=2),
|
json.dumps({"items": items, "splines": splines}, indent=2),
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
return {"status": "ok", "count": len(payload.items), "splines": len(splines)}
|
return {"status": "ok", "count": len(items), "splines": len(splines)}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mdi/font": "^7.4.47",
|
"@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",
|
"axios": "^1.6.5",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"vue": "^3.4.15",
|
"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