This commit is contained in:
2025-12-07 01:16:52 +01:00
parent 13b3c58ec8
commit 78e6909405
22 changed files with 4664 additions and 217 deletions

2405
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "ipwatch-frontend",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"type": "module",
"scripts": {
@@ -9,15 +9,16 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.15",
"@mdi/font": "^7.4.47",
"axios": "^1.6.5",
"pinia": "^2.1.7",
"axios": "^1.6.5"
"vue": "^3.4.15"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.11",
"tailwindcss": "^3.4.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.33"
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"vite": "^5.0.11"
}
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-screen flex flex-col bg-monokai-bg">
<!-- Header -->
<AppHeader />
<AppHeader @openSettings="showSettings = true" />
<!-- Layout 3 colonnes selon consigne-design_webui.md -->
<div class="flex-1 flex overflow-hidden">
@@ -20,20 +20,37 @@
<NewDetections />
</div>
</div>
<!-- Modal Paramètres -->
<SettingsModal
:isOpen="showSettings"
@close="showSettings = false"
@configReloaded="handleConfigReloaded"
/>
</div>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted } 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()
}
onMounted(async () => {
// Charger la configuration UI
await ipStore.fetchUIConfig()
// Charger les données initiales
await ipStore.fetchIPs()

View File

@@ -42,17 +42,22 @@ body {
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: 2px solid;
width: 50px;
height: 50px;
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: 14px;
font-size: var(--font-size, 10px);
}
/* Cases IP - États selon guidelines-css.md */

View File

@@ -4,7 +4,10 @@
<!-- Logo et titre -->
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold text-monokai-green">IPWatch</h1>
<span class="text-monokai-comment">Scanner Réseau</span>
<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 -->
@@ -25,13 +28,37 @@
</div>
</div>
<!-- Dernier scan -->
<div v-if="lastScanDate" class="text-sm text-monokai-comment">
Dernier scan: {{ formatScanDate(lastScanDate) }}
</div>
<!-- Progression du scan -->
<div v-if="isScanning" class="flex items-center gap-2 text-sm">
<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="loading"
:disabled="isScanning"
class="px-4 py-2 rounded bg-monokai-cyan text-monokai-bg font-bold hover:bg-monokai-green transition-colors disabled:opacity-50"
>
{{ loading ? 'Scan en cours...' : 'Lancer Scan' }}
{{ isScanning ? 'Scan en cours...' : 'Lancer Scan' }}
</button>
<!-- Bouton Paramètres -->
<button
@click="openSettings"
class="px-4 py-2 rounded bg-monokai-purple text-monokai-bg text-sm hover:bg-monokai-pink transition-colors"
title="Paramètres"
>
Paramètres
</button>
<!-- Indicateur WebSocket -->
@@ -52,11 +79,29 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useIPStore } from '@/stores/ipStore'
const emit = defineEmits(['openSettings'])
const ipStore = useIPStore()
const { stats, loading, wsConnected } = storeToRefs(ipStore)
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 {
@@ -65,4 +110,26 @@ async function triggerScan() {
console.error('Erreur lancement scan:', err)
}
}
function openSettings() {
emit('openSettings')
}
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

@@ -10,18 +10,13 @@
:title="getTooltip"
>
<!-- Afficher seulement le dernier octet -->
<div class="font-mono font-bold text-2xl">
<div class="font-mono">
{{ lastOctet }}
</div>
<!-- Nom très court si connu -->
<div v-if="ip.name" class="text-xs opacity-75 truncate mt-1">
{{ ip.name }}
</div>
<!-- Indicateur ports ouverts (petit badge) -->
<!-- Indicateur ports ouverts (petit badge décalé et réduit) -->
<div v-if="ip.open_ports && ip.open_ports.length > 0"
class="absolute top-1 right-1 w-2 h-2 rounded-full bg-monokai-cyan">
class="absolute top-0.5 right-0.5 w-1 h-1 rounded-full bg-monokai-cyan">
</div>
</div>
</template>

View File

@@ -1,149 +1,215 @@
<template>
<div class="h-full flex flex-col bg-monokai-bg border-r border-monokai-comment">
<!-- Header -->
<div class="p-4 border-b border-monokai-comment">
<h2 class="text-xl font-bold text-monokai-cyan">Détails IP</h2>
</div>
<!-- Contenu -->
<div class="flex-1 overflow-auto p-4">
<div v-if="selectedIP" class="space-y-4">
<!-- Adresse IP -->
<div>
<div class="text-sm text-monokai-comment mb-1">Adresse IP</div>
<div class="text-2xl font-mono font-bold text-monokai-green">
{{ selectedIP.ip }}
</div>
</div>
<!-- État -->
<div>
<div class="text-sm text-monokai-comment mb-1">État</div>
<div class="flex items-center gap-2">
<div
:class="[
'w-3 h-3 rounded-full',
selectedIP.last_status === 'online' ? 'bg-monokai-green' : 'bg-monokai-pink'
]"
></div>
<span class="text-monokai-text capitalize">
{{ selectedIP.last_status || 'Inconnu' }}
</span>
</div>
</div>
<!-- Formulaire d'édition -->
<div class="space-y-3 pt-4 border-t border-monokai-comment">
<!-- Nom -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Nom</label>
<input
v-model="formData.name"
type="text"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
placeholder="Ex: Serveur Principal"
/>
</div>
<!-- Connue -->
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="formData.known"
type="checkbox"
class="form-checkbox"
/>
<span class="text-sm text-monokai-text">IP connue</span>
</label>
</div>
<!-- Localisation -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Localisation</label>
<input
v-model="formData.location"
type="text"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
placeholder="Ex: Bureau"
/>
</div>
<!-- Type d'hôte -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Type d'hôte</label>
<input
v-model="formData.host"
type="text"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
placeholder="Ex: PC, Serveur, Imprimante"
/>
</div>
<!-- Boutons -->
<div class="flex gap-2 pt-2">
<button
@click="saveChanges"
class="px-4 py-2 bg-monokai-green text-monokai-bg rounded font-bold hover:bg-monokai-cyan transition-colors"
>
Enregistrer
</button>
<button
@click="resetForm"
class="px-4 py-2 bg-monokai-comment text-monokai-bg rounded hover:bg-monokai-text transition-colors"
>
Annuler
</button>
</div>
</div>
<!-- Informations réseau -->
<div class="pt-4 border-t border-monokai-comment space-y-2">
<h3 class="text-monokai-cyan font-bold mb-2">Informations réseau</h3>
<div v-if="selectedIP.mac">
<div class="text-sm text-monokai-comment">MAC</div>
<div class="text-monokai-text font-mono">{{ selectedIP.mac }}</div>
</div>
<div v-if="selectedIP.vendor">
<div class="text-sm text-monokai-comment">Fabricant</div>
<div class="text-monokai-text">{{ selectedIP.vendor }}</div>
</div>
<div v-if="selectedIP.hostname">
<div class="text-sm text-monokai-comment">Hostname</div>
<div class="text-monokai-text font-mono">{{ selectedIP.hostname }}</div>
</div>
<div v-if="selectedIP.open_ports && selectedIP.open_ports.length > 0">
<div class="text-sm text-monokai-comment">Ports ouverts</div>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="port in selectedIP.open_ports"
:key="port"
class="px-2 py-1 bg-monokai-cyan/20 text-monokai-cyan rounded text-xs font-mono"
<div
class="h-full flex flex-col bg-monokai-bg border-r border-monokai-comment"
:style="detailsStyles"
>
<!-- Contenu scrollable -->
<div class="flex-1 overflow-hidden flex flex-col">
<div v-if="selectedIP" class="flex flex-col h-full">
<!-- Section fixe (sticky) : IP et État sur la même ligne -->
<div class="bg-monokai-bg border-b border-monokai-comment" :style="headerPadding">
<div class="flex items-center justify-between gap-2">
<!-- Adresse IP -->
<div class="flex-1 min-w-0">
<a
v-if="selectedIP.link"
:href="selectedIP.link"
target="_blank"
class="font-mono font-bold text-monokai-green hover:text-monokai-cyan transition-colors cursor-pointer underline block truncate"
:style="{ fontSize: ipFontSize }"
>
{{ port }}
{{ selectedIP.ip }}
</a>
<div v-else class="font-mono font-bold text-monokai-green truncate" :style="{ fontSize: ipFontSize }">
{{ selectedIP.ip }}
</div>
</div>
<!-- État -->
<div class="flex items-center gap-1 flex-shrink-0">
<div
:class="[
'w-2 h-2 rounded-full',
selectedIP.last_status === 'online' ? 'bg-monokai-green' : 'bg-monokai-pink'
]"
></div>
<span class="text-monokai-text capitalize text-xs">
{{ selectedIP.last_status || 'Inconnu' }}
</span>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="pt-4 border-t border-monokai-comment space-y-2 text-sm">
<div v-if="selectedIP.first_seen">
<span class="text-monokai-comment">Première vue:</span>
<span class="text-monokai-text ml-2">{{ formatDate(selectedIP.first_seen) }}</span>
<!-- Section scrollable : Formulaire et reste -->
<div class="flex-1 overflow-auto" :style="contentPadding">
<!-- Formulaire d'édition -->
<div :style="formSpacing">
<!-- Nom -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Nom</label>
<input
v-model="formData.name"
type="text"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
placeholder="Ex: Serveur Principal"
/>
</div>
<!-- Connue -->
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="formData.known"
type="checkbox"
class="form-checkbox"
/>
<span class="text-sm text-monokai-text">IP connue</span>
</label>
</div>
<!-- Type d'hôte -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Type d'hôte</label>
<select
v-model="formData.host"
@change="onHostChange"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
>
<option value="">-- Sélectionner --</option>
<option v-for="host in hosts" :key="host.name" :value="host.name">{{ host.name }}</option>
</select>
</div>
<!-- Localisation -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Localisation</label>
<select
v-model="formData.location"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
>
<option value="">-- Sélectionner --</option>
<option v-for="loc in locations" :key="loc" :value="loc">{{ loc }}</option>
</select>
</div>
<!-- Lien -->
<div>
<label class="text-sm text-monokai-comment mb-1 block">Lien (URL)</label>
<input
v-model="formData.link"
type="url"
class="w-full px-3 py-2 bg-monokai-bg border border-monokai-comment rounded text-monokai-text"
placeholder="https://exemple.com"
/>
</div>
</div>
<div v-if="selectedIP.last_seen">
<span class="text-monokai-comment">Dernière vue:</span>
<span class="text-monokai-text ml-2">{{ formatDate(selectedIP.last_seen) }}</span>
<!-- Informations réseau -->
<div class="border-t border-monokai-comment" :style="{ paddingTop: `${uiConfig.details_spacing * 2}px`, marginTop: `${uiConfig.details_spacing * 2}px` }">
<div :style="formSpacing">
<h3 class="text-monokai-cyan font-bold mb-2">Informations réseau</h3>
<div v-if="selectedIP.mac">
<div class="text-sm text-monokai-comment">MAC</div>
<div class="text-monokai-text font-mono">{{ selectedIP.mac }}</div>
</div>
<div v-if="selectedIP.vendor">
<div class="text-sm text-monokai-comment">Fabricant</div>
<div class="text-monokai-text">{{ selectedIP.vendor }}</div>
</div>
<div v-if="selectedIP.hostname">
<div class="text-sm text-monokai-comment">Hostname</div>
<div class="text-monokai-text font-mono">{{ selectedIP.hostname }}</div>
</div>
<div v-if="selectedIP.open_ports && selectedIP.open_ports.length > 0">
<div class="text-sm text-monokai-comment">Ports ouverts</div>
<div class="flex flex-wrap gap-1 mt-1">
<a
v-for="port in selectedIP.open_ports"
:key="port"
:href="getPortUrl(port)"
:target="getPortUrl(port) ? '_blank' : undefined"
:class="[
'px-2 py-1 rounded text-xs font-mono',
getPortUrl(port)
? 'bg-monokai-cyan/20 text-monokai-cyan hover:bg-monokai-cyan/30 cursor-pointer underline'
: 'bg-monokai-cyan/20 text-monokai-cyan cursor-default'
]"
:title="getPortUrl(port) ? `Ouvrir ${portProtocols[port] || portProtocols[String(port)]}://${selectedIP.ip}:${port}` : `Port ${port}`"
>
{{ port }}
</a>
</div>
</div>
<!-- Boutons d'action avec icônes -->
<div class="flex gap-2 justify-center" :style="{ marginTop: `${uiConfig.details_spacing * 3}px` }">
<button
@click="saveChanges"
:class="[
'p-2 rounded transition-colors',
saveButtonState === 'saved'
? 'bg-green-800 text-monokai-text'
: 'bg-monokai-green text-monokai-bg hover:bg-monokai-cyan'
]"
:disabled="saveButtonState === 'saving'"
title="Enregistrer"
>
<span class="mdi mdi-content-save text-xl"></span>
</button>
<button
@click="scanPorts"
:class="[
'p-2 rounded transition-colors',
portScanState === 'scanning'
? 'bg-monokai-orange text-monokai-bg'
: 'bg-monokai-cyan text-monokai-bg hover:bg-monokai-purple'
]"
:disabled="portScanState === 'scanning'"
title="Scanner les ports"
>
<span class="mdi mdi-wifi-settings text-xl"></span>
</button>
<button
@click="resetForm"
class="p-2 bg-monokai-comment text-monokai-bg rounded hover:bg-monokai-text transition-colors"
title="Annuler"
>
<span class="mdi mdi-close-box text-xl"></span>
</button>
<button
@click="deleteIP"
class="p-2 bg-monokai-pink text-monokai-bg rounded hover:bg-red-700 transition-colors"
title="Effacer"
>
<span class="mdi mdi-eraser text-xl"></span>
</button>
</div>
</div>
</div>
<!-- Timestamps -->
<div class="border-t border-monokai-comment text-sm" :style="{ paddingTop: `${uiConfig.details_spacing * 2}px`, marginTop: `${uiConfig.details_spacing * 2}px` }">
<div :style="formSpacing">
<div v-if="selectedIP.first_seen">
<span class="text-monokai-comment">Première vue:</span>
<span class="text-monokai-text ml-2">{{ formatDate(selectedIP.first_seen) }}</span>
</div>
<div v-if="selectedIP.last_seen">
<span class="text-monokai-comment">Dernière vue:</span>
<span class="text-monokai-text ml-2">{{ formatDate(selectedIP.last_seen) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Placeholder si rien sélectionné -->
<div v-else class="text-center text-monokai-comment mt-10">
<div v-else class="text-center text-monokai-comment mt-10 p-4">
<p>Sélectionnez une IP pour voir les détails</p>
</div>
</div>
@@ -151,39 +217,118 @@
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, watch, onMounted, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useIPStore } from '@/stores/ipStore'
const ipStore = useIPStore()
const { selectedIP } = storeToRefs(ipStore)
// Données de configuration depuis config.yaml (chargées dynamiquement)
const locations = ref([])
const hosts = ref([])
const portProtocols = ref({}) // Mapping port -> protocole
const uiConfig = ref({
details_font_size: 13,
details_spacing: 2
})
const formData = ref({
name: '',
known: false,
location: '',
host: ''
host: '',
link: ''
})
// État du bouton enregistrer
const saveButtonState = ref('idle') // 'idle', 'saving', 'saved'
// État du scan de ports
const portScanState = ref('idle') // 'idle', 'scanning'
// Styles dynamiques basés sur la config
const detailsStyles = computed(() => ({
fontSize: `${uiConfig.value.details_font_size}px`
}))
const formSpacing = computed(() => ({
display: 'flex',
flexDirection: 'column',
gap: `${uiConfig.value.details_spacing}px`
}))
const headerPadding = computed(() => ({
padding: `${uiConfig.value.details_spacing * 2}px ${uiConfig.value.details_spacing * 3}px`
}))
const contentPadding = computed(() => ({
padding: `${uiConfig.value.details_spacing * 3}px`
}))
const ipFontSize = computed(() => `${uiConfig.value.details_font_size * 1.2}px`)
// Charger les options de configuration au montage
onMounted(async () => {
try {
const response = await fetch('/api/ips/config/options')
if (response.ok) {
const data = await response.json()
locations.value = data.locations || []
hosts.value = data.hosts || []
portProtocols.value = data.port_protocols || {}
}
} catch (error) {
console.error('Erreur chargement options config:', error)
}
// Charger la config UI depuis le store
if (ipStore.uiConfig) {
uiConfig.value = {
details_font_size: ipStore.uiConfig.details_font_size || 13,
details_spacing: ipStore.uiConfig.details_spacing || 2
}
}
})
// Mettre à jour le formulaire quand l'IP change
watch(selectedIP, (newIP) => {
if (newIP) {
// Auto-remplir l'URL si vide et port 80 ouvert
let autoLink = newIP.link || ''
if (!autoLink && newIP.open_ports && newIP.open_ports.includes(80)) {
autoLink = `http://${newIP.ip}`
}
formData.value = {
name: newIP.name || '',
known: newIP.known || false,
location: newIP.location || '',
host: newIP.host || ''
host: newIP.host || '',
link: autoLink
}
// Réinitialiser l'état du bouton
saveButtonState.value = 'idle'
}
}, { immediate: true })
// Auto-remplir la localisation quand l'hôte change
function onHostChange() {
const selectedHost = hosts.value.find(h => h.name === formData.value.host)
if (selectedHost && selectedHost.location) {
formData.value.location = selectedHost.location
}
}
function resetForm() {
if (selectedIP.value) {
formData.value = {
name: selectedIP.value.name || '',
known: selectedIP.value.known || false,
location: selectedIP.value.location || '',
host: selectedIP.value.host || ''
host: selectedIP.value.host || '',
link: selectedIP.value.link || ''
}
}
}
@@ -192,10 +337,73 @@ async function saveChanges() {
if (!selectedIP.value) return
try {
saveButtonState.value = 'saving'
await ipStore.updateIP(selectedIP.value.ip, formData.value)
saveButtonState.value = 'saved'
console.log('IP mise à jour')
// Retour à l'état normal après 2 secondes
setTimeout(() => {
saveButtonState.value = 'idle'
}, 2000)
} catch (err) {
console.error('Erreur mise à jour IP:', err)
saveButtonState.value = 'idle'
}
}
async function scanPorts() {
if (!selectedIP.value) return
try {
portScanState.value = 'scanning'
const response = await fetch(`/api/scan/ports/${selectedIP.value.ip}`, {
method: 'POST'
})
if (!response.ok) {
throw new Error('Erreur lors du scan de ports')
}
const result = await response.json()
console.log('Scan de ports terminé:', result)
// Rafraîchir les données de l'IP
await ipStore.fetchIPs()
portScanState.value = 'idle'
} catch (err) {
console.error('Erreur scan ports:', err)
portScanState.value = 'idle'
}
}
async function deleteIP() {
if (!selectedIP.value) return
if (confirm(`Voulez-vous vraiment effacer les données de ${selectedIP.value.ip} ?`)) {
try {
// Effacer les données et passer en offline-unknown
const resetData = {
name: '',
known: false,
location: '',
host: '',
link: '',
last_status: 'offline'
}
await ipStore.updateIP(selectedIP.value.ip, resetData)
formData.value = {
name: '',
known: false,
location: '',
host: '',
link: ''
}
console.log('IP effacée')
} catch (err) {
console.error('Erreur effacement IP:', err)
}
}
}
@@ -204,4 +412,28 @@ function formatDate(dateString) {
const date = new Date(dateString)
return date.toLocaleString('fr-FR')
}
// Générer une URL cliquable pour un port
function getPortUrl(port) {
if (!selectedIP.value) return null
// Chercher le protocole (la clé peut être un nombre ou une string)
const protocol = portProtocols.value[port] || portProtocols.value[String(port)]
if (!protocol) return null
const ip = selectedIP.value.ip
// Mapping protocole -> URL
const urlMap = {
'http': `http://${ip}:${port}`,
'https': `https://${ip}:${port}`,
'ssh': `ssh://${ip}:${port}`,
'smb': `smb://${ip}`,
'rdp': `rdp://${ip}:${port}`,
'mysql': `mysql://${ip}:${port}`,
'postgresql': `postgresql://${ip}:${port}`
}
return urlMap[protocol] || null
}
</script>

View File

@@ -24,18 +24,30 @@
</label>
</div>
<!-- Grille d'IPs -->
<!-- Grille d'IPs par sous-réseaux -->
<div class="flex-1 overflow-auto p-4">
<div class="grid grid-cols-4 gap-3">
<IPCell
v-for="ip in filteredIPs"
:key="ip.ip"
:ip="ip"
/>
<!-- 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="filteredIPs.length === 0" class="text-center text-monokai-comment mt-10">
<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>
@@ -70,10 +82,98 @@
</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, filters } = 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

@@ -38,11 +38,12 @@
</div>
<!-- Grille compacte des IPs du sous-réseau -->
<div class="flex flex-wrap gap-2 pl-8">
<div class="flex flex-wrap pl-8 ip-grid">
<IPCell
v-for="ip in subnet.ips"
:key="ip.ip"
:ip="ip"
:is-pinging="scanProgress.currentIP === ip.ip"
/>
</div>
</div>
@@ -89,7 +90,7 @@ import { useIPStore } from '@/stores/ipStore'
import IPCell from './IPCell.vue'
const ipStore = useIPStore()
const { filteredIPs, filters } = storeToRefs(ipStore)
const { filteredIPs, filters, scanProgress } = storeToRefs(ipStore)
// Définition des sous-réseaux (devrait venir de la config mais en dur pour l'instant)
const subnets = [
@@ -98,6 +99,21 @@ const subnets = [
{ name: 'iot', cidr: '10.0.2.0/24', description: 'IoT' }
]
// 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.map(subnet => {
@@ -110,18 +126,21 @@ const organizedSubnets = computed(() => {
return ip.ip.startsWith(prefix + '.')
})
// Trier par ordre numérique
const sortedIPs = sortIPsNumerically(subnetIPs)
// Calculer les stats
const stats = {
total: subnetIPs.length,
online: subnetIPs.filter(ip => ip.last_status === 'online').length,
offline: subnetIPs.filter(ip => ip.last_status === 'offline').length
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: subnetIPs,
ips: sortedIPs,
stats
}
}).filter(subnet => subnet.ips.length > 0) // Ne montrer que les sous-réseaux avec des IPs

View File

@@ -7,46 +7,51 @@
<!-- Liste -->
<div class="flex-1 overflow-auto p-4">
<div v-if="newIPs.length > 0" class="space-y-3">
<div v-if="newIPs.length > 0" class="space-y-2">
<div
v-for="ip in newIPs"
:key="ip.ip"
@click="selectIP(ip)"
class="p-3 rounded border-2 border-monokai-pink bg-monokai-pink/10 cursor-pointer hover:bg-monokai-pink/20 transition-colors"
:class="[
'p-2 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 -->
<div class="font-mono font-bold text-monokai-text">
{{ ip.ip }}
<!-- 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>
<!-- État -->
<div class="text-sm mt-1">
<span
:class="[
'px-2 py-1 rounded text-xs',
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="ml-2 px-2 py-1 rounded text-xs bg-monokai-purple/20 text-monokai-purple"
>
Inconnue
</span>
</div>
<!-- MAC et Vendor -->
<div v-if="ip.mac" class="text-xs text-monokai-comment mt-2 font-mono">
<!-- MAC et Vendor (si disponible) -->
<div v-if="ip.mac" class="text-xs text-monokai-comment mt-1 font-mono">
{{ ip.mac }}
<span v-if="ip.vendor" class="ml-1">({{ ip.vendor }})</span>
</div>
<!-- Timestamp -->
<div class="text-xs text-monokai-comment mt-2">
<div class="text-xs text-monokai-comment mt-1">
{{ formatTime(ip.first_seen) }}
</div>
</div>
@@ -69,15 +74,21 @@ import { useIPStore } from '@/stores/ipStore'
const ipStore = useIPStore()
const { ips } = storeToRefs(ipStore)
// IPs nouvellement détectées (dans les dernières 24h)
// 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)
return firstSeen > oneDayAgo
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)
@@ -91,6 +102,15 @@ 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)

View File

@@ -0,0 +1,193 @@
<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>
<!-- 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 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 response = await fetch('/api/ips/config/content')
if (response.ok) {
const data = await response.json()
configContent.value = data.content
} else {
configContent.value = 'Erreur de chargement du fichier config.yaml'
}
} catch (error) {
console.error('Erreur chargement config:', error)
configContent.value = 'Erreur de chargement du fichier config.yaml'
}
}
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

@@ -2,6 +2,7 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './assets/main.css'
import '@mdi/font/css/materialdesignicons.css'
const app = createApp(App)
const pinia = createPinia()

View File

@@ -18,6 +18,16 @@ export const useIPStore = defineStore('ip', () => {
known: 0,
unknown: 0
})
const lastScanDate = ref(null)
const scanProgress = ref({
current: 0,
total: 0,
currentIP: null
})
const isScanning = ref(false)
const uiConfig = ref({
cell_size: 30
})
// Filtres
const filters = ref({
@@ -51,6 +61,36 @@ export const useIPStore = defineStore('ip', () => {
})
// 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`)
} 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`)
return response.data.message
}
} catch (err) {
console.error('Erreur rechargement config:', err)
throw err
}
}
async function fetchIPs() {
loading.value = true
error.value = null
@@ -97,6 +137,27 @@ export const useIPStore = defineStore('ip', () => {
}
}
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}`)
@@ -172,12 +233,28 @@ export const useIPStore = defineStore('ip', () => {
switch (message.type) {
case 'scan_start':
// Notification début de scan
isScanning.value = true
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()
stats.value = message.stats
if (message.stats) stats.value = message.stats
break
case 'ip_update':
@@ -212,14 +289,21 @@ export const useIPStore = defineStore('ip', () => {
stats,
filters,
wsConnected,
lastScanDate,
scanProgress,
isScanning,
uiConfig,
// Computed
filteredIPs,
// Actions
fetchUIConfig,
reloadConfig,
fetchIPs,
fetchStats,
updateIP,
deleteIP,
getIPHistory,
startScan,
selectIP,

View File

@@ -16,6 +16,7 @@ export default {
pink: '#F92672',
cyan: '#66D9EF',
purple: '#AE81FF',
'purple-dark': '#5E4B8C', // Violet foncé pour détections anciennes
yellow: '#E6DB74',
orange: '#FD971F',
},