This commit is contained in:
2026-02-07 16:57:37 +01:00
parent 8383104454
commit dff1b03e42
129 changed files with 19769 additions and 0 deletions

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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