ipwatch
19
frontend/index.html
Executable file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/android-chrome-512x512.png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IP Watch</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2405
frontend/package-lock.json
generated
Executable file
25
frontend/package.json
Executable file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "ipwatch-frontend",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "^7.4.47",
|
||||
"axios": "^1.6.5",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.0.11"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Executable file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
frontend/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 371 KiB |
BIN
frontend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
frontend/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 877 B |
BIN
frontend/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
frontend/public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
26
frontend/src/App.vue
Executable file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useIPStore } from '@/stores/ipStore'
|
||||
|
||||
const ipStore = useIPStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// Charger la configuration UI
|
||||
await ipStore.fetchUIConfig()
|
||||
|
||||
// Charger les données initiales
|
||||
await ipStore.fetchIPs()
|
||||
|
||||
// Connecter WebSocket
|
||||
ipStore.connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Déconnecter WebSocket
|
||||
ipStore.disconnectWebSocket()
|
||||
})
|
||||
</script>
|
||||
BIN
frontend/src/assets/hardware_benchtools.png
Normal file
|
After Width: | Height: | Size: 980 KiB |
BIN
frontend/src/assets/ipwatch-logo.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
214
frontend/src/assets/main.css
Executable file
@@ -0,0 +1,214 @@
|
||||
/* Styles principaux IPWatch - Thème Monokai */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Variables CSS Monokai */
|
||||
:root {
|
||||
--monokai-bg: #272822;
|
||||
--monokai-text: #F8F8F2;
|
||||
--monokai-comment: #30BF97;
|
||||
--monokai-green: #A6E22E;
|
||||
--monokai-pink: #F92672;
|
||||
--monokai-cyan: #66D9EF;
|
||||
--monokai-purple: #AE81FF;
|
||||
--monokai-yellow: #E6DB74;
|
||||
--monokai-orange: #FD971F;
|
||||
--monokai-blue-dark: #1E3A8A;
|
||||
--monokai-blue-light: #3B82F6;
|
||||
}
|
||||
|
||||
/* Base */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--monokai-bg);
|
||||
color: var(--monokai-text);
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
.no-select {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Animation halo ping */
|
||||
@keyframes ping-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(102, 217, 239, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px 10px rgba(102, 217, 239, 0.3);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(102, 217, 239, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.ping-animation {
|
||||
animation: ping-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Grille des IPs avec espacement configurable */
|
||||
.ip-grid {
|
||||
gap: var(--cell-gap, 2px);
|
||||
}
|
||||
|
||||
/* Cases IP compactes - Version minimale */
|
||||
.ip-cell-compact {
|
||||
@apply rounded cursor-pointer transition-all duration-200 relative;
|
||||
border: 1px solid;
|
||||
width: var(--cell-size, 30px);
|
||||
height: var(--cell-size, 30px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size, 10px);
|
||||
}
|
||||
|
||||
/* Cases IP - États selon guidelines-css.md */
|
||||
.ip-cell {
|
||||
@apply rounded-lg p-3 cursor-pointer transition-all duration-200;
|
||||
border: 2px solid;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* IP libre */
|
||||
.ip-cell.free,
|
||||
.ip-cell-compact.free {
|
||||
background-color: rgba(117, 113, 94, 0.2);
|
||||
border-color: var(--monokai-comment);
|
||||
color: var(--monokai-comment);
|
||||
}
|
||||
|
||||
/* IP en ligne + connue (vert) */
|
||||
.ip-cell.online-known,
|
||||
.ip-cell-compact.online-known {
|
||||
background-color: rgba(166, 226, 46, 0.15);
|
||||
border-color: var(--monokai-green);
|
||||
border-style: solid;
|
||||
color: var(--monokai-text);
|
||||
}
|
||||
|
||||
.ip-cell.online-known:hover,
|
||||
.ip-cell-compact.online-known:hover {
|
||||
background-color: rgba(166, 226, 46, 0.25);
|
||||
}
|
||||
|
||||
/* IP en ligne + inconnue (cyan) */
|
||||
.ip-cell.online-unknown,
|
||||
.ip-cell-compact.online-unknown {
|
||||
background-color: rgba(102, 217, 239, 0.15);
|
||||
border-color: var(--monokai-cyan);
|
||||
border-style: solid;
|
||||
color: var(--monokai-text);
|
||||
}
|
||||
|
||||
.ip-cell.online-unknown:hover,
|
||||
.ip-cell-compact.online-unknown:hover {
|
||||
background-color: rgba(102, 217, 239, 0.25);
|
||||
}
|
||||
|
||||
/* IP hors ligne + connue (rose) */
|
||||
.ip-cell.offline-known,
|
||||
.ip-cell-compact.offline-known {
|
||||
background-color: rgba(249, 38, 114, 0.1);
|
||||
border-color: var(--monokai-pink);
|
||||
border-style: dashed;
|
||||
color: var(--monokai-text);
|
||||
opacity: 0.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* IP hors ligne + inconnue (violet) */
|
||||
.ip-cell.offline-unknown,
|
||||
.ip-cell-compact.offline-unknown {
|
||||
background-color: rgba(174, 129, 255, 0.1);
|
||||
border-color: var(--monokai-purple);
|
||||
border-style: dashed;
|
||||
color: var(--monokai-text);
|
||||
opacity: 0.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Point jaune pour équipements réseau hors ligne - utilise ::after pour échapper à l'opacité du parent */
|
||||
.ip-cell-compact.network-device-offline::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0.375rem; /* 1.5 = 6px */
|
||||
height: 0.375rem;
|
||||
border-radius: 50%;
|
||||
background-color: #FBBF24;
|
||||
box-shadow: 0 0 6px #FBBF24, 0 0 10px rgba(251, 191, 36, 0.6);
|
||||
opacity: 1 !important;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* MAC address changée (orange - alerte) */
|
||||
.ip-cell.mac-changed,
|
||||
.ip-cell-compact.mac-changed {
|
||||
border-color: var(--monokai-orange) !important;
|
||||
border-width: 2px !important;
|
||||
border-style: solid !important;
|
||||
box-shadow: 0 0 10px rgba(253, 151, 31, 0.5);
|
||||
}
|
||||
|
||||
/* Équipements réseau (switches, routeurs, bornes WiFi) */
|
||||
/* En ligne : bordure bleue foncée + fond bleu */
|
||||
.ip-cell.network-device-online,
|
||||
.ip-cell-compact.network-device-online {
|
||||
background-color: rgba(30, 58, 138, 0.25);
|
||||
border-color: var(--monokai-blue-dark);
|
||||
border-style: solid;
|
||||
border-width: 3px;
|
||||
color: var(--monokai-text);
|
||||
}
|
||||
|
||||
.ip-cell.network-device-online:hover,
|
||||
.ip-cell-compact.network-device-online:hover {
|
||||
background-color: rgba(30, 58, 138, 0.35);
|
||||
}
|
||||
|
||||
/* Hors ligne : utilise le style normal (offline-known/offline-unknown) + point bleu en bas */
|
||||
/* Le point bleu est affiché via le template HTML */
|
||||
|
||||
/* Sélection */
|
||||
.ip-cell.selected {
|
||||
box-shadow: 0 0 20px rgba(230, 219, 116, 0.5);
|
||||
border-color: var(--monokai-yellow);
|
||||
}
|
||||
|
||||
.ip-cell-compact.selected {
|
||||
box-shadow:
|
||||
inset 0 0 6px rgba(230, 219, 116, 0.9),
|
||||
inset 0 0 12px rgba(230, 219, 116, 0.6),
|
||||
0 0 6px rgba(230, 219, 116, 0.2);
|
||||
border-color: var(--monokai-yellow);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
/* Scrollbar custom Monokai */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e1f1c;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--monokai-comment);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--monokai-cyan);
|
||||
}
|
||||
186
frontend/src/components/AppHeader.vue
Executable file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<header class="bg-monokai-bg border-b-2 border-monokai-comment p-4 no-select">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Logo et titre -->
|
||||
<div class="flex items-center gap-4">
|
||||
<img :src="logoUrl" alt="IPWatch" class="w-10 h-10" />
|
||||
<h1 class="text-3xl font-bold text-monokai-green">IPWatch</h1>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-monokai-comment">Scanner Réseau</span>
|
||||
<span class="text-xs text-monokai-comment/60">v{{ appVersion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats et contrôles -->
|
||||
<div class="flex items-center gap-6">
|
||||
<!-- Stats système (RAM/CPU) -->
|
||||
<SystemStats />
|
||||
|
||||
<!-- Séparateur -->
|
||||
<div class="h-8 w-px bg-monokai-comment"></div>
|
||||
|
||||
<!-- Statistiques -->
|
||||
<div class="flex gap-4 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-monokai-comment">Total:</span>
|
||||
<span class="text-monokai-text font-bold">{{ stats.total }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-monokai-green"></span>
|
||||
<span class="text-monokai-text">{{ stats.online }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 rounded-full bg-monokai-pink opacity-50"></span>
|
||||
<span class="text-monokai-text">{{ stats.offline }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dernier scan -->
|
||||
<div v-if="lastScanDate" class="text-xs text-monokai-comment">
|
||||
Dernier scan: {{ formatScanDate(lastScanDate) }}
|
||||
</div>
|
||||
|
||||
<!-- Progression du scan -->
|
||||
<div v-if="isScanning" class="flex items-center gap-2 text-xs">
|
||||
<span class="text-monokai-cyan">
|
||||
{{ scanProgress.current }} / {{ scanProgress.total }}
|
||||
</span>
|
||||
<span v-if="scanProgress.currentIP" class="text-monokai-comment">
|
||||
({{ scanProgress.currentIP }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Bouton scan -->
|
||||
<button
|
||||
@click="triggerScan"
|
||||
:disabled="isScanning"
|
||||
class="px-3 py-1.5 rounded bg-monokai-cyan text-monokai-bg text-xs font-bold hover:bg-monokai-green transition-colors disabled:opacity-50"
|
||||
title="Lancer un scan réseau"
|
||||
>
|
||||
{{ isScanning ? 'Scan en cours...' : 'Lancer Scan' }}
|
||||
</button>
|
||||
|
||||
<!-- Bouton Suivi -->
|
||||
<button
|
||||
@click="goToTracking"
|
||||
class="px-3 py-1.5 rounded bg-monokai-yellow text-monokai-bg text-sm font-bold hover:bg-monokai-orange transition-colors"
|
||||
title="Ouvrir la page des équipements suivis"
|
||||
>
|
||||
<span class="mdi mdi-star"></span> Suivi
|
||||
</button>
|
||||
|
||||
<!-- Bouton Architecture -->
|
||||
<button
|
||||
@click="goToArchitecture"
|
||||
class="px-3 py-1.5 rounded bg-monokai-green text-monokai-bg text-sm font-bold hover:bg-monokai-cyan transition-colors"
|
||||
title="Ouvrir la page architecture réseau"
|
||||
>
|
||||
🧭 Architecture
|
||||
</button>
|
||||
|
||||
<!-- Bouton Test -->
|
||||
<button
|
||||
@click="goToTest"
|
||||
class="px-3 py-1.5 rounded bg-monokai-orange text-monokai-bg text-sm font-bold hover:bg-monokai-yellow transition-colors"
|
||||
title="Ouvrir la page de tests réseau"
|
||||
>
|
||||
🧪 Test
|
||||
</button>
|
||||
|
||||
<!-- Bouton Paramètres -->
|
||||
<button
|
||||
@click="openSettings"
|
||||
class="px-3 py-1.5 rounded bg-monokai-purple text-monokai-bg text-sm hover:bg-monokai-pink transition-colors"
|
||||
title="Ouvrir les paramètres"
|
||||
>
|
||||
⚙ Paramètres
|
||||
</button>
|
||||
|
||||
<!-- Indicateur WebSocket -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
:class="[
|
||||
'w-2 h-2 rounded-full',
|
||||
wsConnected ? 'bg-monokai-green' : 'bg-monokai-pink'
|
||||
]"
|
||||
></div>
|
||||
<span class="text-sm text-monokai-comment">
|
||||
{{ wsConnected ? 'Connecté' : 'Déconnecté' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useIPStore } from '@/stores/ipStore'
|
||||
import SystemStats from './SystemStats.vue'
|
||||
import logoUrl from '@/assets/ipwatch-logo.png'
|
||||
|
||||
const emit = defineEmits(['openSettings'])
|
||||
const router = useRouter()
|
||||
|
||||
const ipStore = useIPStore()
|
||||
const { stats, loading, wsConnected, lastScanDate, scanProgress, isScanning } = storeToRefs(ipStore)
|
||||
const appVersion = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
// Charger la version depuis le config
|
||||
try {
|
||||
const response = await fetch('/api/ips/config/options')
|
||||
if (response.ok) {
|
||||
const config = await response.json()
|
||||
appVersion.value = config.version || '1.0.0'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement version:', error)
|
||||
appVersion.value = '1.0.0'
|
||||
}
|
||||
})
|
||||
|
||||
async function triggerScan() {
|
||||
try {
|
||||
await ipStore.startScan()
|
||||
} catch (err) {
|
||||
console.error('Erreur lancement scan:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
emit('openSettings')
|
||||
}
|
||||
|
||||
function goToTracking() {
|
||||
router.push('/tracking')
|
||||
}
|
||||
|
||||
function goToArchitecture() {
|
||||
router.push('/architecture')
|
||||
}
|
||||
|
||||
function goToTest() {
|
||||
router.push('/test')
|
||||
}
|
||||
|
||||
function formatScanDate(date) {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
const now = new Date()
|
||||
const diff = now - d
|
||||
|
||||
if (diff < 60000) return 'il y a quelques secondes'
|
||||
if (diff < 3600000) return `il y a ${Math.floor(diff / 60000)} min`
|
||||
if (diff < 86400000) return `il y a ${Math.floor(diff / 3600000)}h`
|
||||
|
||||
return d.toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
104
frontend/src/components/IPCell.vue
Executable file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'ip-cell-compact',
|
||||
cellClass,
|
||||
{ 'selected': isSelected },
|
||||
{ 'ping-animation': isPinging },
|
||||
{ 'mac-changed': ip.mac_changed },
|
||||
{ 'network-device-offline': ip.network_device && ip.last_status === 'offline' }
|
||||
]"
|
||||
@click="selectThisIP"
|
||||
:title="getTooltip"
|
||||
>
|
||||
<!-- Afficher seulement le dernier octet -->
|
||||
<div class="font-mono">
|
||||
{{ lastOctet }}
|
||||
</div>
|
||||
|
||||
<!-- Indicateur ports ouverts (petit badge décalé et réduit) -->
|
||||
<div v-if="ip.open_ports && ip.open_ports.length > 0"
|
||||
class="absolute top-0.5 right-0.5 w-1 h-1 rounded-full bg-monokai-cyan">
|
||||
</div>
|
||||
|
||||
<!-- Indicateur IP suivie (petit rond rouge en bas) - seulement si pas network_device offline -->
|
||||
<div v-if="ip.tracked && !(ip.network_device && ip.last_status === 'offline')"
|
||||
class="absolute bottom-0.5 left-1/2 transform -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-red-500"
|
||||
title="IP suivie">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useIPStore } from '@/stores/ipStore'
|
||||
|
||||
const props = defineProps({
|
||||
ip: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isPinging: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const ipStore = useIPStore()
|
||||
const { selectedIP } = storeToRefs(ipStore)
|
||||
|
||||
const isSelected = computed(() => {
|
||||
return selectedIP.value?.ip === props.ip.ip
|
||||
})
|
||||
|
||||
// Extraire le dernier octet de l'IP
|
||||
const lastOctet = computed(() => {
|
||||
const parts = props.ip.ip.split('.')
|
||||
return parts[parts.length - 1]
|
||||
})
|
||||
|
||||
// Tooltip avec infos complètes
|
||||
const getTooltip = computed(() => {
|
||||
let tooltip = `${props.ip.ip}`
|
||||
if (props.ip.name) tooltip += ` - ${props.ip.name}`
|
||||
if (props.ip.network_device) tooltip += `\n🔷 Équipement réseau`
|
||||
if (props.ip.hostname) tooltip += `\nHostname: ${props.ip.hostname}`
|
||||
if (props.ip.mac) tooltip += `\nMAC: ${props.ip.mac}`
|
||||
if (props.ip.vendor) tooltip += ` (${props.ip.vendor})`
|
||||
if (props.ip.mac_changed) tooltip += `\n⚠️ MAC ADDRESS CHANGÉE !`
|
||||
if (props.ip.known) tooltip += `\n✅ IP connue`
|
||||
if (props.ip.tracked) tooltip += `\n⭐ IP suivie`
|
||||
if (props.ip.vm) tooltip += `\n🖥️ VM`
|
||||
if (props.ip.hardware_bench) tooltip += `\n🔧 Hardware bench`
|
||||
if (props.ip.open_ports && props.ip.open_ports.length > 0) {
|
||||
tooltip += `\nPorts: ${props.ip.open_ports.join(', ')}`
|
||||
}
|
||||
return tooltip
|
||||
})
|
||||
|
||||
const cellClass = computed(() => {
|
||||
// Équipements réseau EN LIGNE ont bordure bleue
|
||||
if (props.ip.network_device && props.ip.last_status === 'online') {
|
||||
return 'network-device-online'
|
||||
}
|
||||
|
||||
// Équipements réseau HORS LIGNE : style normal (known/unknown) + point bleu
|
||||
// Le point bleu est géré par le template HTML, pas par la classe CSS
|
||||
|
||||
// Déterminer la classe selon l'état (guidelines-css.md)
|
||||
if (!props.ip.last_status) {
|
||||
return 'free'
|
||||
}
|
||||
|
||||
if (props.ip.last_status === 'online') {
|
||||
return props.ip.known ? 'online-known' : 'online-unknown'
|
||||
} else {
|
||||
return props.ip.known ? 'offline-known' : 'offline-unknown'
|
||||
}
|
||||
})
|
||||
|
||||
function selectThisIP() {
|
||||
ipStore.selectIP(props.ip)
|
||||
}
|
||||
</script>
|
||||
1360
frontend/src/components/IPDetails.vue
Executable file
155
frontend/src/components/IPGrid.vue
Executable file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Grille d'IPs par sous-réseaux -->
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<!-- Pour chaque sous-réseau -->
|
||||
<div v-for="subnet in groupedSubnets" :key="subnet.name" class="mb-6">
|
||||
<!-- En-tête de section -->
|
||||
<div class="mb-3 pb-2 border-b-2 border-monokai-cyan/30">
|
||||
<p class="text-xs text-monokai-comment">{{ subnet.name }}</p>
|
||||
<h3 v-if="subnet.start && subnet.end" class="text-lg font-bold text-monokai-cyan mt-0.5">
|
||||
{{ subnet.start }} à {{ subnet.end }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Grille des IPs de ce sous-réseau -->
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
<IPCell
|
||||
v-for="ip in subnet.ips"
|
||||
:key="ip.ip"
|
||||
:ip="ip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message si vide -->
|
||||
<div v-if="groupedSubnets.length === 0" class="text-center text-monokai-comment mt-10">
|
||||
<p>Aucune IP à afficher</p>
|
||||
<p class="text-sm mt-2">Ajustez les filtres ou lancez un scan</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende -->
|
||||
<div class="bg-monokai-bg border-t border-monokai-comment p-3">
|
||||
<div class="flex gap-6 text-xs">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded border-2 border-monokai-green bg-monokai-green/15"></div>
|
||||
<span class="text-monokai-text">En ligne (connue)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded border-2 border-monokai-cyan bg-monokai-cyan/15"></div>
|
||||
<span class="text-monokai-text">En ligne (inconnue)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded border-2 border-dashed border-monokai-pink bg-monokai-pink/10 opacity-50"></div>
|
||||
<span class="text-monokai-text">Hors ligne (connue)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded border-2 border-dashed border-monokai-purple bg-monokai-purple/10 opacity-50"></div>
|
||||
<span class="text-monokai-text">Hors ligne (inconnue)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded border-2 border-monokai-comment bg-monokai-comment/20"></div>
|
||||
<span class="text-monokai-text">Libre</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useIPStore } from '@/stores/ipStore'
|
||||
import IPCell from './IPCell.vue'
|
||||
|
||||
const ipStore = useIPStore()
|
||||
const { filteredIPs } = storeToRefs(ipStore)
|
||||
|
||||
// Subnets depuis la config
|
||||
const subnets = ref([])
|
||||
|
||||
// Charger les subnets depuis la config
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/ips/config/content')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Parser le YAML pour extraire les subnets
|
||||
const yamlContent = data.content
|
||||
const subnetMatches = yamlContent.match(/subnets:[\s\S]*?(?=\n\w+:|\n$)/)?.[0]
|
||||
|
||||
if (subnetMatches) {
|
||||
// Simple parsing des subnets (améliorer si nécessaire)
|
||||
const subnetLines = subnetMatches.split('\n')
|
||||
let currentSubnet = null
|
||||
|
||||
subnetLines.forEach(line => {
|
||||
if (line.includes('- name:')) {
|
||||
if (currentSubnet) subnets.value.push(currentSubnet)
|
||||
currentSubnet = { name: line.split('"')[1] }
|
||||
} else if (currentSubnet) {
|
||||
if (line.includes('start:')) currentSubnet.start = line.split('"')[1]
|
||||
if (line.includes('end:')) currentSubnet.end = line.split('"')[1]
|
||||
if (line.includes('cidr:')) currentSubnet.cidr = line.split('"')[1]
|
||||
}
|
||||
})
|
||||
if (currentSubnet) subnets.value.push(currentSubnet)
|
||||
console.log('=== SUBNETS LOADED V2 ===', Date.now(), subnets.value)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement subnets:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Fonction pour vérifier si une IP appartient à un subnet
|
||||
function ipInSubnet(ip, start, end) {
|
||||
const ipNum = ipToNumber(ip)
|
||||
const startNum = ipToNumber(start)
|
||||
const endNum = ipToNumber(end)
|
||||
return ipNum >= startNum && ipNum <= endNum
|
||||
}
|
||||
|
||||
// Convertir une IP en nombre
|
||||
function ipToNumber(ip) {
|
||||
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) >>> 0
|
||||
}
|
||||
|
||||
// Grouper les IPs par subnet
|
||||
const groupedSubnets = computed(() => {
|
||||
const groups = []
|
||||
|
||||
// Pour chaque subnet défini
|
||||
subnets.value.forEach(subnet => {
|
||||
const subnetIPs = filteredIPs.value.filter(ip =>
|
||||
ipInSubnet(ip.ip, subnet.start, subnet.end)
|
||||
)
|
||||
|
||||
if (subnetIPs.length > 0) {
|
||||
groups.push({
|
||||
name: subnet.name,
|
||||
start: subnet.start,
|
||||
end: subnet.end,
|
||||
ips: subnetIPs.sort((a, b) => ipToNumber(a.ip) - ipToNumber(b.ip))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Ajouter section "Autres" pour les IPs hors subnets
|
||||
const otherIPs = filteredIPs.value.filter(ip => {
|
||||
return !subnets.value.some(subnet => ipInSubnet(ip.ip, subnet.start, subnet.end))
|
||||
})
|
||||
|
||||
if (otherIPs.length > 0) {
|
||||
groups.push({
|
||||
name: 'Autres',
|
||||
start: '',
|
||||
end: '',
|
||||
ips: otherIPs.sort((a, b) => ipToNumber(a.ip) - ipToNumber(b.ip))
|
||||
})
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
</script>
|
||||
162
frontend/src/components/IPGridTree.vue
Executable file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Organisation en arbre par sous-réseaux -->
|
||||
<div class="flex-1 overflow-auto px-1 py-3 no-select">
|
||||
<div v-for="subnet in organizedSubnets" :key="subnet.name" class="mb-3">
|
||||
<!-- Header du sous-réseau (style tree) -->
|
||||
<div class="flex items-center gap-2 mb-2 text-monokai-cyan border-l-4 border-monokai-cyan pl-3 no-select">
|
||||
<span class="font-bold text-lg">{{ subnet.name }}</span>
|
||||
<span class="text-sm text-monokai-text font-semibold">{{ subnet.cidr }}</span>
|
||||
<span class="text-sm text-monokai-text font-semibold ml-auto">
|
||||
{{ subnet.ips.length }} IPs
|
||||
({{ subnet.stats.online }} en ligne)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Grille compacte des IPs du sous-réseau -->
|
||||
<div class="flex flex-wrap pl-2 ip-grid">
|
||||
<IPCell
|
||||
v-for="ip in subnet.ips"
|
||||
:key="ip.ip"
|
||||
:ip="ip"
|
||||
:is-pinging="scanProgress.currentIP === ip.ip"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message si vide -->
|
||||
<div v-if="organizedSubnets.length === 0" class="text-center text-monokai-comment mt-10">
|
||||
<p>Aucune IP à afficher</p>
|
||||
<p class="text-sm mt-2">Ajustez les filtres ou lancez un scan</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende -->
|
||||
<div class="bg-monokai-bg border-t border-monokai-comment px-3 py-2">
|
||||
<div class="flex items-center gap-3 text-[11px] whitespace-nowrap overflow-hidden no-select">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3 h-3 rounded border-2 border-monokai-green bg-monokai-green/15"></div>
|
||||
<span class="text-monokai-text">En ligne (connue)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3 h-3 rounded border-2 border-monokai-cyan bg-monokai-cyan/15"></div>
|
||||
<span class="text-monokai-text">En ligne (inconnue)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3 h-3 rounded border-2 border-dashed border-monokai-pink bg-monokai-pink/10 opacity-50"></div>
|
||||
<span class="text-monokai-text">Hors ligne (connue)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3 h-3 rounded border-2 border-dashed border-monokai-purple bg-monokai-purple/10 opacity-50"></div>
|
||||
<span class="text-monokai-text">Hors ligne (inconnue)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3 h-3 rounded border-2 border-monokai-comment bg-monokai-comment/20"></div>
|
||||
<span class="text-monokai-text">Libre</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3 h-3 rounded border-2 border-monokai-orange" style="box-shadow: 0 0 6px rgba(253, 151, 31, 0.5);"></div>
|
||||
<span class="text-monokai-text">⚠️ MAC changée</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-3 h-3 rounded border-[3px]" style="border-color: #1E3A8A; background-color: rgba(30, 58, 138, 0.25);"></div>
|
||||
<span class="text-monokai-text">Équip. réseau (en ligne)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-1.5 h-1.5 rounded-full" style="background-color: #FBBF24; box-shadow: 0 0 6px #FBBF24, 0 0 10px rgba(251, 191, 36, 0.6);"></div>
|
||||
<span class="text-monokai-text">Équip. réseau (hors ligne)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useIPStore } from '@/stores/ipStore'
|
||||
import IPCell from './IPCell.vue'
|
||||
|
||||
const ipStore = useIPStore()
|
||||
const { filteredIPs, scanProgress } = storeToRefs(ipStore)
|
||||
|
||||
// Sous-réseaux chargés depuis la config
|
||||
const subnets = ref([])
|
||||
|
||||
// Charger les subnets depuis l'API
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/ips/config/options')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.subnets && data.subnets.length > 0) {
|
||||
subnets.value = data.subnets
|
||||
} else {
|
||||
// Fallback si pas de subnets dans la config
|
||||
subnets.value = [
|
||||
{ name: 'static_vm', cidr: '10.0.0.0/24', description: 'Machines virtuelles statiques' },
|
||||
{ name: 'dhcp', cidr: '10.0.1.0/24', description: 'DHCP' },
|
||||
{ name: 'iot', cidr: '10.0.2.0/24', description: 'IoT' },
|
||||
{ name: 'autres', cidr: '10.0.3.0/24', description: 'autres' }
|
||||
]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement subnets:', error)
|
||||
// Fallback en cas d'erreur
|
||||
subnets.value = [
|
||||
{ name: 'static_vm', cidr: '10.0.0.0/24', description: 'Machines virtuelles statiques' },
|
||||
{ name: 'dhcp', cidr: '10.0.1.0/24', description: 'DHCP' },
|
||||
{ name: 'iot', cidr: '10.0.2.0/24', description: 'IoT' },
|
||||
{ name: 'autres', cidr: '10.0.3.0/24', description: 'autres' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Fonction pour trier les IPs par ordre numérique
|
||||
function sortIPsNumerically(ips) {
|
||||
return ips.slice().sort((a, b) => {
|
||||
const partsA = a.ip.split('.').map(Number)
|
||||
const partsB = b.ip.split('.').map(Number)
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (partsA[i] !== partsB[i]) {
|
||||
return partsA[i] - partsB[i]
|
||||
}
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
// Organiser les IPs par sous-réseau
|
||||
const organizedSubnets = computed(() => {
|
||||
return subnets.value.map(subnet => {
|
||||
// Extraire le préfixe du sous-réseau (ex: "10.0.0" pour 10.0.0.0/24)
|
||||
const [oct1, oct2, oct3] = subnet.cidr.split('/')[0].split('.')
|
||||
const prefix = `${oct1}.${oct2}.${oct3}`
|
||||
|
||||
// Filtrer les IPs qui appartiennent à ce sous-réseau
|
||||
const subnetIPs = filteredIPs.value.filter(ip => {
|
||||
return ip.ip.startsWith(prefix + '.')
|
||||
})
|
||||
|
||||
// Trier par ordre numérique
|
||||
const sortedIPs = sortIPsNumerically(subnetIPs)
|
||||
|
||||
// Calculer les stats
|
||||
const stats = {
|
||||
total: sortedIPs.length,
|
||||
online: sortedIPs.filter(ip => ip.last_status === 'online').length,
|
||||
offline: sortedIPs.filter(ip => ip.last_status === 'offline').length
|
||||
}
|
||||
|
||||
return {
|
||||
name: subnet.name,
|
||||
cidr: subnet.cidr,
|
||||
description: subnet.description,
|
||||
ips: sortedIPs,
|
||||
stats
|
||||
}
|
||||
}).filter(subnet => subnet.ips.length > 0) // Ne montrer que les sous-réseaux avec des IPs
|
||||
})
|
||||
</script>
|
||||
137
frontend/src/components/NewDetections.vue
Executable file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-monokai-bg border-l border-monokai-comment no-select">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-monokai-comment">
|
||||
<h2 class="text-xl font-bold text-monokai-pink">Nouvelles Détections</h2>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<div v-if="newIPs.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="ip in newIPs"
|
||||
:key="ip.ip"
|
||||
@click="selectIP(ip)"
|
||||
:class="[
|
||||
'p-2.5 rounded border-2 cursor-pointer transition-colors',
|
||||
isOlderThanOneHour(ip.first_seen)
|
||||
? 'border-monokai-purple-dark bg-monokai-purple-dark/10 hover:bg-monokai-purple-dark/20'
|
||||
: 'border-monokai-pink bg-monokai-pink/10 hover:bg-monokai-pink/20'
|
||||
]"
|
||||
>
|
||||
<!-- IP + Statut sur la même ligne -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono font-bold text-monokai-text text-sm">
|
||||
{{ ip.ip }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded',
|
||||
ip.last_status === 'online'
|
||||
? 'bg-monokai-green/20 text-monokai-green'
|
||||
: 'bg-monokai-comment/20 text-monokai-comment'
|
||||
]"
|
||||
>
|
||||
{{ ip.last_status || 'Inconnu' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="!ip.known"
|
||||
class="px-2 py-0.5 rounded bg-monokai-cyan/20 text-monokai-cyan"
|
||||
>
|
||||
Inconnue
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAC/Vendor + Timestamp sur la même ligne -->
|
||||
<div class="flex items-center justify-between text-xs text-monokai-comment mt-1">
|
||||
<div v-if="ip.mac" class="font-mono">
|
||||
{{ ip.mac }}
|
||||
<span v-if="ip.vendor" class="ml-1">({{ ip.vendor }})</span>
|
||||
</div>
|
||||
<div>{{ formatTime(ip.first_seen) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder -->
|
||||
<div v-else class="text-center text-monokai-comment mt-10">
|
||||
<p>Aucune nouvelle IP détectée</p>
|
||||
<p class="text-sm mt-2">Les nouvelles IPs apparaîtront ici</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useIPStore } from '@/stores/ipStore'
|
||||
|
||||
const ipStore = useIPStore()
|
||||
const { ips } = storeToRefs(ipStore)
|
||||
|
||||
// IPs nouvellement détectées (dans les dernières 24h ET online ET non enregistrées)
|
||||
const newIPs = computed(() => {
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
|
||||
return ips.value
|
||||
.filter(ip => {
|
||||
// Doit être online
|
||||
if (ip.last_status !== 'online') return false
|
||||
// Doit avoir un first_seen récent
|
||||
if (!ip.first_seen) return false
|
||||
const firstSeen = new Date(ip.first_seen)
|
||||
if (firstSeen <= oneDayAgo) return false
|
||||
// Ne pas afficher les IPs déjà enregistrées (avec nom ou connue)
|
||||
if (ip.known || ip.name) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.first_seen)
|
||||
const dateB = new Date(b.first_seen)
|
||||
return dateB - dateA // Plus récent en premier
|
||||
})
|
||||
.slice(0, 20) // Limiter à 20
|
||||
})
|
||||
|
||||
function selectIP(ip) {
|
||||
ipStore.selectIP(ip)
|
||||
}
|
||||
|
||||
// Vérifier si la détection date de plus d'une heure
|
||||
function isOlderThanOneHour(dateString) {
|
||||
if (!dateString) return false
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
return diff >= 3600000 // 1 heure = 3600000 ms
|
||||
}
|
||||
|
||||
function formatTime(dateString) {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
// Moins d'une minute
|
||||
if (diff < 60000) {
|
||||
return 'À l\'instant'
|
||||
}
|
||||
|
||||
// Moins d'une heure
|
||||
if (diff < 3600000) {
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
return `Il y a ${minutes} min`
|
||||
}
|
||||
|
||||
// Moins de 24h
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
return `Il y a ${hours}h`
|
||||
}
|
||||
|
||||
return date.toLocaleString('fr-FR')
|
||||
}
|
||||
</script>
|
||||
440
frontend/src/components/SettingsModal.vue
Executable file
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="bg-monokai-bg border-2 border-monokai-cyan rounded-lg w-[800px] max-h-[80vh] flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-monokai-comment flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-monokai-cyan">Paramètres</h2>
|
||||
<button
|
||||
@click="close"
|
||||
class="text-monokai-comment hover:text-monokai-text text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-auto p-4 space-y-4">
|
||||
<!-- Boutons d'action -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="reloadConfig"
|
||||
:disabled="loading"
|
||||
class="px-4 py-2 bg-monokai-green text-monokai-bg rounded hover:bg-monokai-cyan transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? 'Chargement...' : 'Recharger Config' }}
|
||||
</button>
|
||||
|
||||
<label class="px-4 py-2 bg-monokai-cyan text-monokai-bg rounded hover:bg-monokai-green transition-colors cursor-pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept=".xml"
|
||||
@change="importIpscan"
|
||||
class="hidden"
|
||||
/>
|
||||
Importer IPScan XML
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Lien hardware bench -->
|
||||
<div class="border border-monokai-comment rounded p-3">
|
||||
<div class="text-sm font-bold text-monokai-cyan mb-2">Lien hardware bench</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="hardwareBenchUrl"
|
||||
type="url"
|
||||
class="flex-1 px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text text-sm"
|
||||
placeholder="http://10.0.0.50:8087/devices.html"
|
||||
/>
|
||||
<button
|
||||
@click="saveHardwareBenchUrl"
|
||||
:disabled="loading"
|
||||
class="px-3 py-2 bg-monokai-purple text-monokai-bg rounded hover:bg-monokai-pink transition-colors disabled:opacity-50"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Base OUI -->
|
||||
<div class="border border-monokai-comment rounded p-3">
|
||||
<div class="text-sm font-bold text-monokai-cyan mb-2">Base OUI (fabricants)</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="text-xs text-monokai-comment">
|
||||
<span v-if="ouiStatus.exists">Dernière MAJ: {{ formatDate(ouiStatus.updated_at) }}</span>
|
||||
<span v-else>Aucune liste locale</span>
|
||||
</div>
|
||||
<button
|
||||
@click="updateOui"
|
||||
:disabled="loading"
|
||||
class="px-3 py-2 bg-monokai-green text-monokai-bg rounded hover:bg-monokai-cyan transition-colors disabled:opacity-50"
|
||||
>
|
||||
Mettre à jour
|
||||
</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 mt-3 text-xs text-monokai-text">
|
||||
<input v-model="forceVendorUpdate" type="checkbox" class="form-checkbox" @change="saveForceVendor" />
|
||||
Forcer la mise à jour du fabricant lors des scans
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Architecture -->
|
||||
<div class="border border-monokai-comment rounded p-3">
|
||||
<div class="text-sm font-bold text-monokai-cyan mb-2">Architecture</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-monokai-comment w-40">Taille titres</label>
|
||||
<input
|
||||
v-model.number="architectureTitleFontSize"
|
||||
type="number"
|
||||
min="10"
|
||||
max="32"
|
||||
class="w-24 px-2 py-1 bg-monokai-bg border border-monokai-comment rounded text-monokai-text text-xs"
|
||||
/>
|
||||
<button
|
||||
@click="saveArchitectureUi"
|
||||
:disabled="loading"
|
||||
class="px-3 py-2 bg-monokai-purple text-monokai-bg rounded hover:bg-monokai-pink transition-colors disabled:opacity-50"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-if="message" :class="['p-3 rounded', messageClass]">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<!-- Contenu config.yaml -->
|
||||
<div>
|
||||
<h3 class="text-monokai-cyan font-bold mb-2">Fichier config.yaml</h3>
|
||||
<pre class="bg-monokai-bg border border-monokai-comment rounded p-4 text-sm text-monokai-text overflow-auto max-h-[400px] font-mono">{{ configContent }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-4 border-t border-monokai-comment flex justify-end">
|
||||
<button
|
||||
@click="close"
|
||||
class="px-4 py-2 bg-monokai-comment text-monokai-bg rounded hover:bg-monokai-text transition-colors"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'configReloaded'])
|
||||
|
||||
const configContent = ref('')
|
||||
const hardwareBenchUrl = ref('')
|
||||
const ouiStatus = ref({ exists: false, updated_at: null })
|
||||
const forceVendorUpdate = ref(false)
|
||||
const architectureTitleFontSize = ref(18)
|
||||
const loading = ref(false)
|
||||
const message = ref('')
|
||||
const messageType = ref('info')
|
||||
|
||||
// Computed class pour le message
|
||||
const messageClass = ref('')
|
||||
|
||||
watch(() => props.isOpen, async (newVal) => {
|
||||
if (newVal) {
|
||||
await loadConfigContent()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadConfigContent() {
|
||||
try {
|
||||
const [contentResponse, optionsResponse, ouiResponse, uiResponse] = await Promise.all([
|
||||
fetch('/api/ips/config/content'),
|
||||
fetch('/api/ips/config/options'),
|
||||
fetch('/api/ips/oui/status'),
|
||||
fetch('/api/config/ui')
|
||||
])
|
||||
if (contentResponse.ok) {
|
||||
const data = await contentResponse.json()
|
||||
configContent.value = data.content
|
||||
} else {
|
||||
configContent.value = 'Erreur de chargement du fichier config.yaml'
|
||||
}
|
||||
|
||||
if (optionsResponse.ok) {
|
||||
const options = await optionsResponse.json()
|
||||
hardwareBenchUrl.value = options.hardware_bench_url || ''
|
||||
forceVendorUpdate.value = Boolean(options.force_vendor_update)
|
||||
}
|
||||
|
||||
if (ouiResponse.ok) {
|
||||
ouiStatus.value = await ouiResponse.json()
|
||||
}
|
||||
|
||||
if (uiResponse.ok) {
|
||||
const uiData = await uiResponse.json()
|
||||
architectureTitleFontSize.value = Number(uiData.architecture_title_font_size || 18)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement config:', error)
|
||||
configContent.value = 'Erreur de chargement du fichier config.yaml'
|
||||
}
|
||||
}
|
||||
|
||||
async function updateOui() {
|
||||
loading.value = true
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ips/oui/update', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
message.value = `${data.message} (${data.updated_vendors} mises à jour)`
|
||||
messageType.value = 'success'
|
||||
messageClass.value = 'bg-monokai-green/20 text-monokai-green border border-monokai-green'
|
||||
await loadConfigContent()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
message.value = error.detail || 'Erreur lors de la mise à jour'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur mise à jour OUI:', error)
|
||||
message.value = 'Erreur de connexion au serveur'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
} finally {
|
||||
loading.value = false
|
||||
setTimeout(() => {
|
||||
message.value = ''
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveForceVendor() {
|
||||
loading.value = true
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ips/config/force-vendor', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ enabled: forceVendorUpdate.value })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
message.value = data.message
|
||||
messageType.value = 'success'
|
||||
messageClass.value = 'bg-monokai-green/20 text-monokai-green border border-monokai-green'
|
||||
await loadConfigContent()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
message.value = error.detail || 'Erreur lors de la mise à jour'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur mise à jour force vendeur:', error)
|
||||
message.value = 'Erreur de connexion au serveur'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
} finally {
|
||||
loading.value = false
|
||||
setTimeout(() => {
|
||||
message.value = ''
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveArchitectureUi() {
|
||||
loading.value = true
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/ui', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
architecture_title_font_size: architectureTitleFontSize.value
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
message.value = data.message
|
||||
messageType.value = 'success'
|
||||
messageClass.value = 'bg-monokai-green/20 text-monokai-green border border-monokai-green'
|
||||
document.documentElement.style.setProperty(
|
||||
'--arch-title-size',
|
||||
`${architectureTitleFontSize.value}px`
|
||||
)
|
||||
} else {
|
||||
const error = await response.json()
|
||||
message.value = error.detail || 'Erreur lors de la mise à jour'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur mise à jour UI architecture:', error)
|
||||
message.value = 'Erreur de connexion au serveur'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
} finally {
|
||||
loading.value = false
|
||||
setTimeout(() => {
|
||||
message.value = ''
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'N/A'
|
||||
const date = new Date(value)
|
||||
return date.toLocaleString('fr-FR')
|
||||
}
|
||||
|
||||
async function saveHardwareBenchUrl() {
|
||||
loading.value = true
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ips/config/hardware-bench', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ url: hardwareBenchUrl.value })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
message.value = data.message
|
||||
messageType.value = 'success'
|
||||
messageClass.value = 'bg-monokai-green/20 text-monokai-green border border-monokai-green'
|
||||
emit('configReloaded')
|
||||
await loadConfigContent()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
message.value = error.detail || 'Erreur lors de la mise à jour'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur mise à jour lien hardware bench:', error)
|
||||
message.value = 'Erreur de connexion au serveur'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
} finally {
|
||||
loading.value = false
|
||||
setTimeout(() => {
|
||||
message.value = ''
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadConfig() {
|
||||
loading.value = true
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ips/config/reload', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
message.value = data.message
|
||||
messageType.value = 'success'
|
||||
messageClass.value = 'bg-monokai-green/20 text-monokai-green border border-monokai-green'
|
||||
emit('configReloaded')
|
||||
|
||||
// Recharger le contenu du config
|
||||
await loadConfigContent()
|
||||
} else {
|
||||
const error = await response.json()
|
||||
message.value = error.detail || 'Erreur lors du rechargement'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur rechargement config:', error)
|
||||
message.value = 'Erreur de connexion au serveur'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
} finally {
|
||||
loading.value = false
|
||||
setTimeout(() => {
|
||||
message.value = ''
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
async function importIpscan(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
|
||||
loading.value = true
|
||||
message.value = ''
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ips/import/ipscan', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
message.value = `Import réussi: ${data.imported} nouvelles IPs, ${data.updated} mises à jour`
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
message.value += `\nErreurs: ${data.errors.join(', ')}`
|
||||
}
|
||||
messageType.value = 'success'
|
||||
messageClass.value = 'bg-monokai-green/20 text-monokai-green border border-monokai-green'
|
||||
emit('configReloaded') // Rafraîchir la liste des IPs
|
||||
} else {
|
||||
const error = await response.json()
|
||||
message.value = error.detail || 'Erreur lors de l\'import'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur import:', error)
|
||||
message.value = 'Erreur de connexion au serveur'
|
||||
messageType.value = 'error'
|
||||
messageClass.value = 'bg-monokai-pink/20 text-monokai-pink border border-monokai-pink'
|
||||
} finally {
|
||||
loading.value = false
|
||||
event.target.value = '' // Reset input
|
||||
setTimeout(() => {
|
||||
message.value = ''
|
||||
}, 8000)
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
190
frontend/src/components/SystemStats.vue
Executable file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<!-- Indicateur CPU -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] text-monokai-comment">CPU</span>
|
||||
<span
|
||||
:class="[
|
||||
'font-mono font-bold text-[11px]',
|
||||
cpuColorClass
|
||||
]"
|
||||
>
|
||||
{{ stats.cpu_percent }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Séparateur -->
|
||||
<div class="h-6 w-px bg-monokai-comment"></div>
|
||||
|
||||
<!-- Indicateur RAM Système -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] text-monokai-comment">RAM PC</span>
|
||||
<span
|
||||
:class="[
|
||||
'font-mono font-bold text-[11px]',
|
||||
ramColorClass
|
||||
]"
|
||||
>
|
||||
{{ stats.ram_used }} MB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Séparateur -->
|
||||
<div class="h-6 w-px bg-monokai-comment"></div>
|
||||
|
||||
<!-- Indicateur RAM App -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-[11px] text-monokai-comment">RAM App</span>
|
||||
<span class="font-mono font-bold text-[11px] text-monokai-purple">
|
||||
{{ stats.process_ram_mb }} MB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip détaillé au survol -->
|
||||
<div class="relative group">
|
||||
<button
|
||||
class="text-monokai-comment hover:text-monokai-cyan transition-colors text-xs"
|
||||
title="Détails système"
|
||||
>
|
||||
ⓘ
|
||||
</button>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div
|
||||
class="absolute right-0 top-full mt-2 w-64 bg-monokai-bg border border-monokai-comment rounded-lg p-3 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
|
||||
>
|
||||
<div class="text-xs space-y-2">
|
||||
<!-- CPU détaillé -->
|
||||
<div>
|
||||
<div class="text-monokai-cyan font-bold mb-1">Processeur</div>
|
||||
<div class="flex justify-between text-monokai-text">
|
||||
<span>Utilisation:</span>
|
||||
<span class="font-mono">{{ stats.cpu_percent }}%</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-monokai-text">
|
||||
<span>Cœurs:</span>
|
||||
<span class="font-mono">{{ stats.cpu_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-monokai-comment"></div>
|
||||
|
||||
<!-- RAM détaillée -->
|
||||
<div>
|
||||
<div class="text-monokai-green font-bold mb-1">Mémoire</div>
|
||||
<div class="flex justify-between text-monokai-text">
|
||||
<span>Utilisée:</span>
|
||||
<span class="font-mono">{{ stats.ram_used }} MB</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-monokai-text">
|
||||
<span>Disponible:</span>
|
||||
<span class="font-mono">{{ stats.ram_available }} MB</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-monokai-text">
|
||||
<span>Total:</span>
|
||||
<span class="font-mono">{{ stats.ram_total }} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-monokai-comment"></div>
|
||||
|
||||
<!-- Processus IPWatch -->
|
||||
<div>
|
||||
<div class="text-monokai-purple font-bold mb-1">IPWatch</div>
|
||||
<div class="flex justify-between text-monokai-text">
|
||||
<span>RAM:</span>
|
||||
<span class="font-mono">{{ stats.process_ram_mb }} MB</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-monokai-text">
|
||||
<span>CPU:</span>
|
||||
<span class="font-mono">{{ formatPercent(stats.process_cpu_percent) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Icône refresh -->
|
||||
<button
|
||||
@click="fetchStats"
|
||||
:disabled="loading"
|
||||
class="text-monokai-comment hover:text-monokai-cyan transition-colors disabled:opacity-50"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
title="Rafraîchir"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const stats = ref({
|
||||
cpu_percent: 0,
|
||||
cpu_count: 0,
|
||||
ram_percent: 0,
|
||||
ram_used: 0,
|
||||
ram_total: 0,
|
||||
ram_available: 0,
|
||||
process_ram_mb: 0,
|
||||
process_cpu_percent: 0
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
let intervalId = null
|
||||
|
||||
// Couleur CPU selon le pourcentage
|
||||
const cpuColorClass = computed(() => {
|
||||
const percent = stats.value.cpu_percent
|
||||
if (percent >= 80) return 'text-monokai-pink'
|
||||
if (percent >= 60) return 'text-monokai-yellow'
|
||||
return 'text-monokai-cyan'
|
||||
})
|
||||
|
||||
// Couleur RAM selon le pourcentage
|
||||
const ramColorClass = computed(() => {
|
||||
const percent = stats.value.ram_percent
|
||||
if (percent >= 80) return 'text-monokai-pink'
|
||||
if (percent >= 60) return 'text-monokai-yellow'
|
||||
return 'text-monokai-green'
|
||||
})
|
||||
|
||||
function formatPercent(value) {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return '0.0'
|
||||
return Number(value).toFixed(1)
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetch('/api/system/stats')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
stats.value = data
|
||||
} else {
|
||||
// Erreur HTTP silencieuse - pas de log pour éviter le spam
|
||||
// Les stats précédentes restent affichées
|
||||
}
|
||||
} catch (error) {
|
||||
// Erreur réseau silencieuse (déconnexion temporaire, etc.)
|
||||
// Les stats précédentes restent affichées
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Charger immédiatement
|
||||
fetchStats()
|
||||
|
||||
// Rafraîchir toutes les 5 secondes
|
||||
intervalId = setInterval(fetchStats, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
13
frontend/src/main.js
Executable file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { router } from './router'
|
||||
import App from './App.vue'
|
||||
import './assets/main.css'
|
||||
import '@mdi/font/css/materialdesignicons.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
55
frontend/src/router/index.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Configuration du router Vue Router pour IPWatch
|
||||
* Gère la navigation entre la page principale et la page de suivi
|
||||
*/
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import MainView from '@/views/MainView.vue'
|
||||
import TrackingView from '@/views/TrackingView.vue'
|
||||
import ArchitectureView from '@/views/ArchitectureView.vue'
|
||||
import TestView from '@/views/TestView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'main',
|
||||
component: MainView,
|
||||
meta: {
|
||||
title: 'IPWatch - Scanner réseau'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/tracking',
|
||||
name: 'tracking',
|
||||
component: TrackingView,
|
||||
meta: {
|
||||
title: 'IPWatch - Équipements suivis'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/architecture',
|
||||
name: 'architecture',
|
||||
component: ArchitectureView,
|
||||
meta: {
|
||||
title: 'IPWatch - Architecture réseau'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/test',
|
||||
name: 'test',
|
||||
component: TestView,
|
||||
meta: {
|
||||
title: 'IPWatch - Tests réseau'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// Mise à jour du titre de page
|
||||
router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title || 'IPWatch'
|
||||
next()
|
||||
})
|
||||
450
frontend/src/stores/ipStore.js
Executable file
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* Store Pinia pour la gestion des IPs
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
export const useIPStore = defineStore('ip', () => {
|
||||
// État
|
||||
const ips = ref([])
|
||||
const selectedIP = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const stats = ref({
|
||||
total: 0,
|
||||
online: 0,
|
||||
offline: 0,
|
||||
known: 0,
|
||||
unknown: 0
|
||||
})
|
||||
const searchQuery = ref('')
|
||||
const invertSearch = ref(false)
|
||||
const lastScanDate = ref(null)
|
||||
const scanProgress = ref({
|
||||
current: 0,
|
||||
total: 0,
|
||||
currentIP: null
|
||||
})
|
||||
const scanLogs = ref([])
|
||||
const isScanning = ref(false)
|
||||
const uiConfig = ref({
|
||||
cell_size: 30,
|
||||
architecture_title_font_size: 18
|
||||
})
|
||||
const configReloadTick = ref(0)
|
||||
|
||||
// WebSocket
|
||||
const ws = ref(null)
|
||||
const wsConnected = ref(false)
|
||||
|
||||
// Computed
|
||||
const filteredIPs = computed(() => {
|
||||
const { tokens, flags } = parseSearchQuery(searchQuery.value)
|
||||
|
||||
return ips.value.filter(ip => {
|
||||
if (flags.requireOnline && ip.last_status !== 'online') return false
|
||||
if (flags.requireOffline && ip.last_status !== 'offline') return false
|
||||
if (flags.requireFree && ip.last_status) return false
|
||||
if (flags.requireKnown && !ip.known) return false
|
||||
if (flags.requireUnknown && ip.known) return false
|
||||
if (flags.requireTracked && !ip.tracked) return false
|
||||
if (flags.requireVm && !ip.vm) return false
|
||||
if (flags.requireHardwareBench && !ip.hardware_bench) return false
|
||||
|
||||
let matches = true
|
||||
|
||||
if (tokens.length === 0) {
|
||||
matches = true
|
||||
} else {
|
||||
const haystack = normalizeText([
|
||||
ip.ip,
|
||||
ip.name,
|
||||
ip.hostname,
|
||||
ip.host,
|
||||
ip.location,
|
||||
ip.mac,
|
||||
ip.vendor,
|
||||
ip.link,
|
||||
ip.last_status,
|
||||
ip.tracked ? 'suivie' : '',
|
||||
ip.vm ? 'vm' : '',
|
||||
ip.hardware_bench ? 'hardware' : '',
|
||||
(ip.open_ports || []).join(' ')
|
||||
].filter(Boolean).join(' '))
|
||||
|
||||
// Match si au moins un mot est présent
|
||||
matches = tokens.some(token => haystack.includes(token))
|
||||
}
|
||||
|
||||
return invertSearch.value ? !matches : matches
|
||||
})
|
||||
})
|
||||
|
||||
// Actions
|
||||
async function fetchUIConfig() {
|
||||
try {
|
||||
const response = await axios.get('/api/config/ui')
|
||||
uiConfig.value = response.data
|
||||
// Appliquer la taille des cellules, de la police et de l'espacement via variables CSS
|
||||
document.documentElement.style.setProperty('--cell-size', `${response.data.cell_size}px`)
|
||||
document.documentElement.style.setProperty('--font-size', `${response.data.font_size}px`)
|
||||
document.documentElement.style.setProperty('--cell-gap', `${response.data.cell_gap}px`)
|
||||
document.documentElement.style.setProperty(
|
||||
'--arch-title-size',
|
||||
`${response.data.architecture_title_font_size}px`
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement config UI:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadConfig() {
|
||||
try {
|
||||
const response = await axios.post('/api/config/reload')
|
||||
if (response.data.success) {
|
||||
// Appliquer la nouvelle config UI
|
||||
uiConfig.value = response.data.ui
|
||||
document.documentElement.style.setProperty('--cell-size', `${response.data.ui.cell_size}px`)
|
||||
document.documentElement.style.setProperty('--font-size', `${response.data.ui.font_size}px`)
|
||||
document.documentElement.style.setProperty('--cell-gap', `${response.data.ui.cell_gap}px`)
|
||||
document.documentElement.style.setProperty(
|
||||
'--arch-title-size',
|
||||
`${response.data.ui.architecture_title_font_size}px`
|
||||
)
|
||||
return response.data.message
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur rechargement config:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function bumpConfigReload() {
|
||||
configReloadTick.value += 1
|
||||
}
|
||||
|
||||
async function fetchIPs() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/ips/')
|
||||
ips.value = response.data
|
||||
await fetchStats()
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
console.error('Erreur chargement IPs:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await axios.get('/api/ips/stats/summary')
|
||||
stats.value = response.data
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateIP(ipAddress, data) {
|
||||
try {
|
||||
const response = await axios.put(`/api/ips/${ipAddress}`, data)
|
||||
|
||||
// Mettre à jour dans le store
|
||||
const index = ips.value.findIndex(ip => ip.ip === ipAddress)
|
||||
if (index !== -1) {
|
||||
ips.value[index] = response.data
|
||||
}
|
||||
|
||||
if (selectedIP.value?.ip === ipAddress) {
|
||||
selectedIP.value = response.data
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIP(ipAddress) {
|
||||
try {
|
||||
await axios.delete(`/api/ips/${ipAddress}`)
|
||||
|
||||
// Retirer du store
|
||||
const index = ips.value.findIndex(ip => ip.ip === ipAddress)
|
||||
if (index !== -1) {
|
||||
ips.value.splice(index, 1)
|
||||
}
|
||||
|
||||
if (selectedIP.value?.ip === ipAddress) {
|
||||
selectedIP.value = null
|
||||
}
|
||||
|
||||
await fetchStats()
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function getIPHistory(ipAddress, hours = 24) {
|
||||
try {
|
||||
const response = await axios.get(`/api/ips/${ipAddress}/history?hours=${hours}`)
|
||||
return response.data
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement historique:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function startScan() {
|
||||
try {
|
||||
await axios.post('/api/scan/start')
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function selectIP(ip) {
|
||||
selectedIP.value = ip
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIP.value = null
|
||||
}
|
||||
|
||||
// WebSocket
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`
|
||||
|
||||
ws.value = new WebSocket(wsUrl)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('WebSocket connecté')
|
||||
wsConnected.value = true
|
||||
|
||||
// Heartbeat toutes les 30s
|
||||
setInterval(() => {
|
||||
if (ws.value?.readyState === WebSocket.OPEN) {
|
||||
ws.value.send('ping')
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
// Ignorer les messages ping/pong (texte brut)
|
||||
if (typeof event.data === 'string' && (event.data === 'ping' || event.data === 'pong')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const message = JSON.parse(event.data)
|
||||
handleWebSocketMessage(message)
|
||||
} catch (err) {
|
||||
// Ignorer silencieusement les erreurs de parsing pour les messages non-JSON
|
||||
}
|
||||
}
|
||||
|
||||
ws.value.onerror = (error) => {
|
||||
// Erreur WebSocket - ne pas logger si c'est juste une déconnexion normale
|
||||
wsConnected.value = false
|
||||
}
|
||||
|
||||
ws.value.onclose = (event) => {
|
||||
wsConnected.value = false
|
||||
|
||||
// Ne logger que si c'est une fermeture anormale
|
||||
if (!event.wasClean) {
|
||||
console.log('WebSocket déconnecté - reconnexion dans 5s...')
|
||||
}
|
||||
|
||||
// Reconnexion après 5s
|
||||
setTimeout(() => {
|
||||
// Ne reconnecter que si on n'est pas déjà connecté
|
||||
if (!ws.value || ws.value.readyState === WebSocket.CLOSED) {
|
||||
connectWebSocket()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
function handleWebSocketMessage(message) {
|
||||
// Logger uniquement les messages importants (pas scan_progress pour éviter le spam)
|
||||
if (message.type !== 'scan_progress') {
|
||||
console.log('WebSocket:', message.type)
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'scan_start':
|
||||
// Notification début de scan
|
||||
isScanning.value = true
|
||||
scanLogs.value = []
|
||||
scanProgress.value = {
|
||||
current: 0,
|
||||
total: message.total || 0,
|
||||
currentIP: null
|
||||
}
|
||||
break
|
||||
|
||||
case 'scan_progress':
|
||||
// Progression du scan
|
||||
if (message.current) scanProgress.value.current = message.current
|
||||
if (message.total) scanProgress.value.total = message.total
|
||||
if (message.ip) scanProgress.value.currentIP = message.ip
|
||||
break
|
||||
|
||||
case 'scan_complete':
|
||||
// Rafraîchir les données après scan
|
||||
isScanning.value = false
|
||||
lastScanDate.value = new Date()
|
||||
scanProgress.value = { current: 0, total: 0, currentIP: null }
|
||||
fetchIPs()
|
||||
if (message.stats) stats.value = message.stats
|
||||
break
|
||||
|
||||
case 'ip_update':
|
||||
// Mise à jour d'une IP
|
||||
const updatedIP = ips.value.find(ip => ip.ip === message.data.ip)
|
||||
if (updatedIP) {
|
||||
Object.assign(updatedIP, message.data)
|
||||
}
|
||||
break
|
||||
|
||||
case 'new_ip':
|
||||
// Nouvelle IP détectée
|
||||
fetchIPs() // Recharger pour être sûr
|
||||
break
|
||||
case 'scan_log':
|
||||
if (message.message) {
|
||||
scanLogs.value.push(message.message)
|
||||
if (scanLogs.value.length > 200) {
|
||||
scanLogs.value.splice(0, scanLogs.value.length - 200)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectWebSocket() {
|
||||
if (ws.value) {
|
||||
ws.value.close()
|
||||
ws.value = null
|
||||
wsConnected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// État
|
||||
ips,
|
||||
selectedIP,
|
||||
loading,
|
||||
error,
|
||||
stats,
|
||||
searchQuery,
|
||||
invertSearch,
|
||||
wsConnected,
|
||||
lastScanDate,
|
||||
scanProgress,
|
||||
scanLogs,
|
||||
isScanning,
|
||||
uiConfig,
|
||||
configReloadTick,
|
||||
|
||||
// Computed
|
||||
filteredIPs,
|
||||
|
||||
// Actions
|
||||
fetchUIConfig,
|
||||
reloadConfig,
|
||||
bumpConfigReload,
|
||||
fetchIPs,
|
||||
fetchStats,
|
||||
updateIP,
|
||||
deleteIP,
|
||||
getIPHistory,
|
||||
startScan,
|
||||
selectIP,
|
||||
clearSelection,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket
|
||||
}
|
||||
})
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
}
|
||||
|
||||
function parseSearchQuery(query) {
|
||||
let normalized = normalizeText(query)
|
||||
|
||||
const flags = {
|
||||
requireOnline: false,
|
||||
requireOffline: false,
|
||||
requireKnown: false,
|
||||
requireUnknown: false,
|
||||
requireFree: false,
|
||||
requireTracked: false,
|
||||
requireVm: false,
|
||||
requireHardwareBench: false
|
||||
}
|
||||
|
||||
const onlinePattern = /\ben\s+ligne\b/g
|
||||
const offlinePattern = /\bhors\s+ligne\b/g
|
||||
|
||||
if (onlinePattern.test(normalized)) flags.requireOnline = true
|
||||
if (offlinePattern.test(normalized)) flags.requireOffline = true
|
||||
|
||||
normalized = normalized
|
||||
.replace(onlinePattern, ' ')
|
||||
.replace(offlinePattern, ' ')
|
||||
|
||||
let tokens = normalized.split(/\s+/).filter(Boolean)
|
||||
|
||||
tokens = tokens.filter(token => {
|
||||
if (token === 'connue') {
|
||||
flags.requireKnown = true
|
||||
return false
|
||||
}
|
||||
if (token === 'inconnue') {
|
||||
flags.requireUnknown = true
|
||||
return false
|
||||
}
|
||||
if (token === 'libre') {
|
||||
flags.requireFree = true
|
||||
return false
|
||||
}
|
||||
if (token === 'suivie' || token === 'suivi') {
|
||||
flags.requireTracked = true
|
||||
return false
|
||||
}
|
||||
if (token === 'vm') {
|
||||
flags.requireVm = true
|
||||
return false
|
||||
}
|
||||
if (token === 'hardware' || token === 'bench' || token === 'hardware_bench') {
|
||||
flags.requireHardwareBench = true
|
||||
return false
|
||||
}
|
||||
if (token === 'enligne' || token === 'en-ligne') {
|
||||
flags.requireOnline = true
|
||||
return false
|
||||
}
|
||||
if (token === 'horsligne' || token === 'hors-ligne') {
|
||||
flags.requireOffline = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return { tokens, flags }
|
||||
}
|
||||
1542
frontend/src/views/ArchitectureView.vue
Normal file
51
frontend/src/views/MainView.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-monokai-bg">
|
||||
<!-- Header -->
|
||||
<AppHeader @openSettings="showSettings = true" />
|
||||
|
||||
<!-- Layout 3 colonnes selon consigne-design_webui.md -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Colonne gauche: Détails IP -->
|
||||
<div class="w-80 flex-shrink-0">
|
||||
<IPDetails :showFilters="false" />
|
||||
</div>
|
||||
|
||||
<!-- Colonne centrale: Grille d'IP organisée en arbre -->
|
||||
<div class="flex-1">
|
||||
<IPGridTree />
|
||||
</div>
|
||||
|
||||
<!-- Colonne droite: Nouvelles détections + Filtres -->
|
||||
<div class="w-80 flex-shrink-0 flex flex-col">
|
||||
<NewDetections class="flex-1" />
|
||||
<IPDetails :showOnlyFilters="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Paramètres -->
|
||||
<SettingsModal
|
||||
:isOpen="showSettings"
|
||||
@close="showSettings = false"
|
||||
@configReloaded="handleConfigReloaded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useIPStore } from '@/stores/ipStore'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import IPDetails from '@/components/IPDetails.vue'
|
||||
import IPGridTree from '@/components/IPGridTree.vue'
|
||||
import NewDetections from '@/components/NewDetections.vue'
|
||||
import SettingsModal from '@/components/SettingsModal.vue'
|
||||
|
||||
const ipStore = useIPStore()
|
||||
const showSettings = ref(false)
|
||||
|
||||
async function handleConfigReloaded() {
|
||||
// Recharger les IPs après un import ou un rechargement de config
|
||||
await ipStore.fetchIPs()
|
||||
ipStore.bumpConfigReload()
|
||||
}
|
||||
</script>
|
||||
151
frontend/src/views/TestView.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-monokai-bg">
|
||||
<!-- Header -->
|
||||
<header class="bg-monokai-bg border-b-2 border-monokai-comment p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-3xl font-bold text-monokai-green">IPWatch</h1>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-monokai-comment">Tests réseau</span>
|
||||
<span class="text-xs text-monokai-comment/60">bêta</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<SystemStats />
|
||||
|
||||
<div class="h-8 w-px bg-monokai-comment"></div>
|
||||
|
||||
<button
|
||||
@click="goToDashboard"
|
||||
class="px-4 py-2 rounded bg-monokai-yellow text-monokai-bg font-bold hover:bg-monokai-orange transition-colors"
|
||||
title="Retour au dashboard"
|
||||
>
|
||||
← Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="openSettings"
|
||||
class="px-4 py-2 rounded bg-monokai-purple text-monokai-bg text-sm hover:bg-monokai-pink transition-colors"
|
||||
title="Ouvrir les paramètres"
|
||||
>
|
||||
⚙ Paramètres
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Layout -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<div class="w-80 flex-shrink-0 border-r border-monokai-comment">
|
||||
<div class="p-4">
|
||||
<h2 class="text-lg font-bold text-monokai-cyan">Commandes</h2>
|
||||
<p class="text-sm text-monokai-comment mt-2">
|
||||
Commandes de test (à venir).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<div class="border border-dashed border-monokai-comment rounded-lg h-full flex items-center justify-center text-monokai-comment">
|
||||
<div class="text-center">
|
||||
<div class="text-xl text-monokai-cyan font-bold">Zone de test</div>
|
||||
<div class="text-sm mt-2">Ping, traceroute, etc.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex-shrink-0 border-l border-monokai-comment"
|
||||
:style="{ width: `${rightPanelWidth}px` }"
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize"
|
||||
@mousedown.prevent="startResize"
|
||||
></div>
|
||||
<div class="absolute left-1 top-1/2 -translate-y-1/2 flex flex-col gap-1 opacity-70 pointer-events-none">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-monokai-comment/70"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-monokai-comment/70"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-monokai-comment/70"></span>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-monokai-comment/70"></span>
|
||||
</div>
|
||||
<div class="p-4 h-full flex flex-col">
|
||||
<h2 class="text-lg font-bold text-monokai-pink">Historique</h2>
|
||||
<div class="mt-3 bg-monokai-bg border border-monokai-comment rounded flex-1 min-h-0 overflow-auto">
|
||||
<pre class="text-xs text-monokai-text font-mono p-3 whitespace-pre-wrap">
|
||||
{{ scanLogs.length ? scanLogs.join('\n') : 'Aucun log pour le moment.' }}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsModal
|
||||
:isOpen="showSettings"
|
||||
@close="showSettings = false"
|
||||
@configReloaded="handleConfigReloaded"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useIPStore } from '@/stores/ipStore'
|
||||
import SystemStats from '@/components/SystemStats.vue'
|
||||
import SettingsModal from '@/components/SettingsModal.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const router = useRouter()
|
||||
const ipStore = useIPStore()
|
||||
const showSettings = ref(false)
|
||||
const { scanLogs } = storeToRefs(ipStore)
|
||||
const rightPanelWidth = ref(384)
|
||||
const minPanelWidth = 288
|
||||
const maxPanelWidth = 576
|
||||
let isResizing = false
|
||||
let startX = 0
|
||||
let startWidth = 0
|
||||
|
||||
function openSettings() {
|
||||
showSettings.value = true
|
||||
}
|
||||
|
||||
function goToDashboard() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
async function handleConfigReloaded() {
|
||||
await ipStore.fetchIPs()
|
||||
ipStore.bumpConfigReload()
|
||||
}
|
||||
|
||||
function clampWidth(value) {
|
||||
return Math.max(minPanelWidth, Math.min(maxPanelWidth, value))
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
if (!isResizing) return
|
||||
const delta = startX - event.clientX
|
||||
rightPanelWidth.value = clampWidth(startWidth + delta)
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (!isResizing) return
|
||||
isResizing = false
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
function startResize(event) {
|
||||
isResizing = true
|
||||
startX = event.clientX
|
||||
startWidth = rightPanelWidth.value
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopResize()
|
||||
})
|
||||
</script>
|
||||
251
frontend/src/views/TrackingView.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="h-screen flex flex-col bg-monokai-bg">
|
||||
<!-- Header -->
|
||||
<header class="bg-monokai-bg border-b-2 border-monokai-comment p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="text-monokai-cyan hover:text-monokai-green transition-colors"
|
||||
title="Retour à la page principale"
|
||||
>
|
||||
<span class="mdi mdi-arrow-left text-2xl"></span>
|
||||
</button>
|
||||
<h1 class="text-3xl font-bold text-monokai-green">Équipements suivis</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@click="refreshTrackedIPs"
|
||||
:disabled="loading"
|
||||
class="px-4 py-2 rounded bg-monokai-cyan text-monokai-bg font-bold hover:bg-monokai-green transition-colors disabled:opacity-50"
|
||||
title="Rafraîchir la liste"
|
||||
>
|
||||
<span class="mdi mdi-refresh" :class="{ 'animate-spin': loading }"></span> Rafraîchir
|
||||
</button>
|
||||
<div class="text-sm text-monokai-comment">
|
||||
{{ trackedIPs.length }} équipement(s) suivi(s)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Liste des équipements -->
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<!-- Message si aucun équipement -->
|
||||
<div v-if="!loading && trackedIPs.length === 0" class="text-center text-monokai-comment mt-20">
|
||||
<span class="mdi mdi-information-outline text-6xl mb-4 block"></span>
|
||||
<p class="text-xl mb-2">Aucun équipement suivi</p>
|
||||
<p class="text-sm">Cochez "IP suivie" dans les détails d'une IP sur la page principale</p>
|
||||
<button
|
||||
@click="goBack"
|
||||
class="mt-6 px-6 py-3 rounded bg-monokai-green text-monokai-bg font-bold hover:bg-monokai-cyan transition-colors"
|
||||
>
|
||||
<span class="mdi mdi-arrow-left"></span> Retour à la page principale
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loader -->
|
||||
<div v-if="loading" class="text-center text-monokai-cyan mt-20">
|
||||
<span class="mdi mdi-loading mdi-spin text-6xl"></span>
|
||||
<p class="mt-4">Chargement des équipements...</p>
|
||||
</div>
|
||||
|
||||
<!-- Grille des équipements -->
|
||||
<div v-else-if="trackedIPs.length > 0" class="grid gap-2 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<div
|
||||
v-for="ip in trackedIPs"
|
||||
:key="ip.ip"
|
||||
class="bg-monokai-bg border-2 rounded-lg p-2 transition-all hover:shadow-lg"
|
||||
:class="[
|
||||
ip.last_status === 'online'
|
||||
? 'border-monokai-green shadow-monokai-green/20'
|
||||
: 'border-monokai-pink opacity-60'
|
||||
]"
|
||||
>
|
||||
<!-- En-tête équipement -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-bold text-monokai-text truncate" :title="ip.name || ip.ip">
|
||||
{{ ip.name || 'Sans nom' }}
|
||||
</h3>
|
||||
<p class="text-xs text-monokai-comment font-mono">{{ ip.ip }}</p>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'w-2.5 h-2.5 rounded-full flex-shrink-0 ml-2',
|
||||
ip.last_status === 'online' ? 'bg-monokai-green' : 'bg-monokai-pink'
|
||||
]"
|
||||
:title="ip.last_status === 'online' ? 'En ligne' : 'Hors ligne'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Métadonnées compactes -->
|
||||
<div class="space-y-0.5 mb-2 text-xs border-t border-monokai-comment/30 pt-2">
|
||||
<div v-if="ip.host" class="flex items-center gap-1.5">
|
||||
<span class="mdi mdi-server text-monokai-cyan text-sm"></span>
|
||||
<span class="text-monokai-text font-semibold truncate">{{ ip.host }}</span>
|
||||
</div>
|
||||
<div v-if="ip.location" class="flex items-center gap-1.5">
|
||||
<span class="mdi mdi-map-marker text-monokai-yellow text-sm"></span>
|
||||
<span class="text-monokai-text truncate">{{ ip.location }}</span>
|
||||
</div>
|
||||
<div v-if="ip.mac" class="flex items-center gap-1.5">
|
||||
<span class="mdi mdi-ethernet text-monokai-purple text-sm"></span>
|
||||
<span class="text-monokai-comment font-mono text-xs truncate" :title="ip.mac">{{ ip.mac.substring(0, 17) }}</span>
|
||||
</div>
|
||||
<div v-if="ip.vendor && ip.vendor !== 'Unknown'" class="flex items-center gap-1.5">
|
||||
<span class="mdi mdi-factory text-monokai-orange text-sm"></span>
|
||||
<span class="text-monokai-text text-xs truncate" :title="ip.vendor">{{ ip.vendor }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions compactes -->
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
@click="wakeOnLan(ip)"
|
||||
:disabled="ip.last_status === 'online' || !ip.mac || actionLoading[ip.ip]"
|
||||
class="flex-1 px-2 py-1.5 rounded bg-monokai-green text-monokai-bg font-bold hover:bg-monokai-cyan transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-xs"
|
||||
:title="!ip.mac ? 'Adresse MAC requise' : ip.last_status === 'online' ? 'Déjà en ligne' : 'Démarrer via Wake-on-LAN'"
|
||||
>
|
||||
<span class="mdi" :class="actionLoading[ip.ip] === 'wol' ? 'mdi-loading mdi-spin' : 'mdi-power'"></span>
|
||||
WOL
|
||||
</button>
|
||||
<button
|
||||
@click="shutdownDevice(ip)"
|
||||
:disabled="ip.last_status === 'offline' || actionLoading[ip.ip]"
|
||||
class="flex-1 px-2 py-1.5 rounded bg-monokai-pink text-monokai-bg font-bold hover:bg-red-700 transition-colors disabled:opacity-30 disabled:cursor-not-allowed text-xs"
|
||||
:title="ip.last_status === 'offline' ? 'Déjà hors ligne' : 'Éteindre l\'équipement'"
|
||||
>
|
||||
<span class="mdi" :class="actionLoading[ip.ip] === 'shutdown' ? 'mdi-loading mdi-spin' : 'mdi-power-off'"></span>
|
||||
Éteindre
|
||||
</button>
|
||||
<button
|
||||
@click="viewDetails(ip)"
|
||||
class="px-2 py-1.5 rounded bg-monokai-purple text-monokai-bg hover:bg-monokai-cyan transition-colors text-xs"
|
||||
title="Voir les détails"
|
||||
>
|
||||
<span class="mdi mdi-information"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import axios from 'axios'
|
||||
|
||||
const router = useRouter()
|
||||
const trackedIPs = ref([])
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref({}) // Pour suivre les actions en cours par IP
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchTrackedIPs()
|
||||
})
|
||||
|
||||
async function fetchTrackedIPs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/tracking/')
|
||||
trackedIPs.value = response.data
|
||||
} catch (error) {
|
||||
console.error('Erreur chargement IPs suivies:', error)
|
||||
alert(`Erreur lors du chargement des équipements suivis: ${error.message}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTrackedIPs() {
|
||||
await fetchTrackedIPs()
|
||||
}
|
||||
|
||||
async function wakeOnLan(ip) {
|
||||
if (!ip.mac) {
|
||||
alert('Adresse MAC manquante. Impossible d\'envoyer le paquet Wake-on-LAN.')
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value[ip.ip] = 'wol'
|
||||
try {
|
||||
await axios.post(`/api/tracking/wol/${ip.ip}`)
|
||||
alert(`✓ Paquet Wake-on-LAN envoyé à ${ip.name || ip.ip}.\n\nL'équipement devrait démarrer dans quelques secondes (si Wake-on-LAN est activé dans le BIOS).`)
|
||||
|
||||
// Rafraîchir après quelques secondes
|
||||
setTimeout(async () => {
|
||||
await fetchTrackedIPs()
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('Erreur WOL:', error)
|
||||
const errorMsg = error.response?.data?.detail || error.message
|
||||
alert(`✗ Erreur Wake-on-LAN: ${errorMsg}`)
|
||||
} finally {
|
||||
delete actionLoading.value[ip.ip]
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdownDevice(ip) {
|
||||
if (!confirm(`Voulez-vous vraiment éteindre ${ip.name || ip.ip} ?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value[ip.ip] = 'shutdown'
|
||||
try {
|
||||
await axios.post(`/api/tracking/shutdown/${ip.ip}`)
|
||||
alert(`✓ Commande d'arrêt envoyée à ${ip.name || ip.ip}`)
|
||||
|
||||
// Rafraîchir après quelques secondes
|
||||
setTimeout(async () => {
|
||||
await fetchTrackedIPs()
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('Erreur shutdown:', error)
|
||||
const errorMsg = error.response?.data?.detail || error.message
|
||||
|
||||
// Message spécifique si non implémenté
|
||||
if (error.response?.status === 501) {
|
||||
alert(`⚠ Fonctionnalité non implémentée\n\n${errorMsg}\n\nCette fonctionnalité nécessite une configuration supplémentaire selon votre infrastructure (SSH, WMI, SNMP, etc.).`)
|
||||
} else {
|
||||
alert(`✗ Erreur arrêt: ${errorMsg}`)
|
||||
}
|
||||
} finally {
|
||||
delete actionLoading.value[ip.ip]
|
||||
}
|
||||
}
|
||||
|
||||
function viewDetails(ip) {
|
||||
// Retourner à la page principale avec l'IP sélectionnée
|
||||
router.push({ path: '/', query: { select: ip.ip } })
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A'
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'À l\'instant'
|
||||
if (diffMins < 60) return `Il y a ${diffMins} min`
|
||||
if (diffHours < 24) return `Il y a ${diffHours}h`
|
||||
if (diffDays < 7) return `Il y a ${diffDays}j`
|
||||
|
||||
return date.toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
27
frontend/tailwind.config.js
Executable file
@@ -0,0 +1,27 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Palette Monokai (guidelines-css.md)
|
||||
monokai: {
|
||||
bg: '#272822',
|
||||
text: '#F8F8F2',
|
||||
comment: '#75715E',
|
||||
green: '#A6E22E',
|
||||
pink: '#F92672',
|
||||
cyan: '#66D9EF',
|
||||
purple: '#AE81FF',
|
||||
'purple-dark': '#5E4B8C', // Violet foncé pour détections anciennes
|
||||
yellow: '#E6DB74',
|
||||
orange: '#FD971F',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/vite.config.js
Executable file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||