scan port

This commit is contained in:
2026-02-07 18:53:18 +01:00
parent dff1b03e42
commit 4eb3defa59
11 changed files with 1037 additions and 3 deletions

View File

@@ -69,6 +69,15 @@
<span class="mdi mdi-star"></span> Suivi
</button>
<!-- Bouton Ports & Services -->
<button
@click="goToServices"
class="px-3 py-1.5 rounded bg-monokai-cyan/80 text-monokai-bg text-sm font-bold hover:bg-monokai-cyan transition-colors"
title="Scanner les services réseau"
>
<span class="mdi mdi-lan-check"></span> Ports & Services
</button>
<!-- Bouton Architecture -->
<button
@click="goToArchitecture"
@@ -158,6 +167,10 @@ function goToTracking() {
router.push('/tracking')
}
function goToServices() {
router.push('/services')
}
function goToArchitecture() {
router.push('/architecture')
}

View File

@@ -7,6 +7,7 @@ import MainView from '@/views/MainView.vue'
import TrackingView from '@/views/TrackingView.vue'
import ArchitectureView from '@/views/ArchitectureView.vue'
import TestView from '@/views/TestView.vue'
import ServicesView from '@/views/ServicesView.vue'
const routes = [
{
@@ -33,6 +34,14 @@ const routes = [
title: 'IPWatch - Architecture réseau'
}
},
{
path: '/services',
name: 'services',
component: ServicesView,
meta: {
title: 'IPWatch - Ports & Services'
}
},
{
path: '/test',
name: 'test',

View File

@@ -348,6 +348,7 @@ export const useIPStore = defineStore('ip', () => {
stats,
searchQuery,
invertSearch,
ws,
wsConnected,
lastScanDate,
scanProgress,

View File

@@ -0,0 +1,567 @@
<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">Ports & Services</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"
>
<span class="mdi mdi-arrow-left"></span> 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"
>
<span class="mdi mdi-cog"></span> Paramètres
</button>
</div>
</div>
</header>
<!-- Layout 3 colonnes -->
<div class="flex-1 flex overflow-hidden">
<!-- Volet gauche : Liste des services -->
<div class="w-80 flex-shrink-0 border-r border-monokai-comment flex flex-col">
<div class="p-4 border-b border-monokai-comment">
<h2 class="text-lg font-bold text-monokai-cyan flex items-center gap-2">
<span class="mdi mdi-server-network"></span>
Services
<span class="text-xs text-monokai-comment font-normal">({{ selectedCount }}/{{ services.length }})</span>
</h2>
</div>
<!-- Liste scrollable des services -->
<div class="flex-1 overflow-auto p-2">
<div
v-for="service in services"
:key="service.name + service.port"
class="flex items-center gap-2 px-3 py-1.5 rounded hover:bg-monokai-comment/20 cursor-pointer transition-colors"
@click="toggleService(service)"
>
<input
type="checkbox"
:checked="isSelected(service)"
class="form-checkbox accent-monokai-cyan"
@click.stop
@change="toggleService(service)"
/>
<span class="flex-1 text-sm text-monokai-text truncate">{{ service.name }}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-monokai-comment/30 text-monokai-comment font-mono">
{{ service.port }}
</span>
<span v-if="service.protocol" class="text-xs text-monokai-purple">
{{ service.protocol }}
</span>
</div>
<div v-if="services.length === 0" class="text-center text-monokai-comment text-sm p-4">
Aucun service configuré dans config.yaml
</div>
</div>
<!-- Boutons de sélection -->
<div class="p-3 border-t border-monokai-comment flex gap-2">
<button
@click="selectAll"
class="flex-1 px-2 py-1.5 rounded text-xs font-bold bg-monokai-comment/30 text-monokai-text hover:bg-monokai-comment/50 transition-colors"
>
Tout cocher
</button>
<button
@click="selectNone"
class="flex-1 px-2 py-1.5 rounded text-xs font-bold bg-monokai-comment/30 text-monokai-text hover:bg-monokai-comment/50 transition-colors"
>
Tout décocher
</button>
</div>
</div>
<!-- Zone centrale : Scan et résultats -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Barre d'action -->
<div class="p-4 border-b border-monokai-comment">
<div class="flex items-center gap-4">
<button
@click="launchScan"
:disabled="selectedCount === 0 || isScanning"
class="px-4 py-2 rounded font-bold transition-colors"
:class="selectedCount === 0 || isScanning
? 'bg-monokai-comment/30 text-monokai-comment cursor-not-allowed'
: 'bg-monokai-cyan text-monokai-bg hover:bg-monokai-green'"
>
<span class="mdi mdi-magnify-scan"></span>
{{ isScanning ? 'Scan en cours...' : 'Lancer le scan' }}
</button>
<!-- Progression -->
<div v-if="isScanning" class="flex items-center gap-3 flex-1">
<div class="flex-1 h-2 bg-monokai-comment/30 rounded-full overflow-hidden">
<div
class="h-full bg-monokai-cyan transition-all duration-300 rounded-full"
:style="{ width: progressPercent + '%' }"
></div>
</div>
<span class="text-xs text-monokai-cyan whitespace-nowrap">
{{ scanProgress.current }} / {{ scanProgress.total }}
</span>
<span v-if="scanProgress.ip" class="text-xs text-monokai-comment">
{{ scanProgress.ip }}
</span>
</div>
<!-- Stats résultat -->
<div v-if="!isScanning && results.length > 0" class="text-sm text-monokai-comment">
{{ filteredResults.length }} service(s) détecté(s)
<span v-if="filterText" class="text-monokai-purple">(filtré)</span>
</div>
</div>
<!-- Message informatif sous le scan -->
<div v-if="isScanning" class="mt-2 text-xs text-monokai-comment flex items-center gap-2">
<span class="mdi mdi-information-outline text-monokai-cyan"></span>
Scan de {{ selectedCount }} service(s) sur l'ensemble des IPs connues du réseau.
Chaque IP est testée avec un timeout de {{ scanTimeout }}s.
<span v-if="scanProgress.total > 0" class="text-monokai-yellow">
Temps estimé : ~{{ estimatedTime }}
</span>
</div>
</div>
<!-- Tableau de résultats -->
<div v-if="results.length > 0 || isScanning" class="flex-1 overflow-auto">
<!-- Filtre texte -->
<div class="px-4 py-2 sticky top-0 bg-monokai-bg border-b border-monokai-comment/50 z-10">
<input
v-model="filterText"
type="text"
placeholder="Filtrer les résultats (IP, nom, service...)"
class="w-full px-3 py-1.5 rounded bg-monokai-comment/20 text-monokai-text text-sm border border-monokai-comment/30 focus:border-monokai-cyan focus:outline-none"
/>
</div>
<table class="w-full text-sm">
<thead class="sticky top-[41px] bg-monokai-bg z-10">
<tr class="text-monokai-comment border-b border-monokai-comment">
<th class="text-left px-4 py-2 cursor-pointer hover:text-monokai-text" @click="sortBy('ip')">
IP {{ sortIcon('ip') }}
</th>
<th class="text-left px-4 py-2 cursor-pointer hover:text-monokai-text" @click="sortBy('name')">
Nom {{ sortIcon('name') }}
</th>
<th class="text-left px-4 py-2 cursor-pointer hover:text-monokai-text" @click="sortBy('service_name')">
Service {{ sortIcon('service_name') }}
</th>
<th class="text-left px-4 py-2 cursor-pointer hover:text-monokai-text" @click="sortBy('port')">
Port {{ sortIcon('port') }}
</th>
<th class="text-left px-4 py-2">Lien</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, index) in filteredResults"
:key="row.ip + '-' + row.port"
class="border-b border-monokai-comment/20 hover:bg-monokai-comment/10 transition-colors"
:class="index % 2 === 0 ? 'bg-monokai-bg' : 'bg-monokai-comment/5'"
>
<td class="px-4 py-2 font-mono text-monokai-cyan">{{ row.ip }}</td>
<td class="px-4 py-2 text-monokai-text">{{ row.name || row.hostname || '-' }}</td>
<td class="px-4 py-2">
<span class="px-2 py-0.5 rounded text-xs font-bold"
:class="serviceColor(row.protocol)">
{{ row.service_name }}
</span>
</td>
<td class="px-4 py-2 font-mono text-monokai-purple">{{ row.port }}</td>
<td class="px-4 py-2">
<a
v-if="row.url"
:href="row.url"
target="_blank"
rel="noopener noreferrer"
class="text-monokai-green hover:text-monokai-yellow underline flex items-center gap-1"
>
<span class="mdi mdi-open-in-new text-xs"></span>
{{ row.url }}
</a>
<span v-else-if="row.protocol" class="text-monokai-comment text-xs">
{{ row.protocol }}://{{ row.ip }}:{{ row.port }}
</span>
<span v-else class="text-monokai-comment text-xs">{{ row.ip }}:{{ row.port }}</span>
</td>
</tr>
<!-- Message pendant le scan si pas encore de résultats -->
<tr v-if="filteredResults.length === 0 && isScanning">
<td colspan="5" class="px-4 py-8 text-center text-monokai-comment">
<span class="mdi mdi-magnify-scan text-2xl block mb-2 animate-pulse"></span>
Scan en cours, en attente de résultats...
</td>
</tr>
</tbody>
</table>
</div>
<!-- État vide -->
<div v-else-if="!isScanning" class="flex-1 flex items-center justify-center">
<div class="text-center text-monokai-comment">
<span class="mdi mdi-lan-check text-6xl block mb-4 opacity-30"></span>
<div class="text-lg">Sélectionnez des services et lancez un scan</div>
<div class="text-sm mt-2 opacity-60">
Les résultats afficheront les services actifs sur chaque IP du réseau
</div>
</div>
</div>
</div>
<!-- Volet droit : Logs -->
<div
class="relative flex-shrink-0 border-l border-monokai-comment"
:style="{ width: `${rightPanelWidth}px` }"
>
<!-- Poignée de redimensionnement -->
<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 flex items-center gap-2">
<span class="mdi mdi-text-box-outline"></span>
Logs du scan
</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">{{ logs.length ? logs.join('\n') : 'Aucun log pour le moment.' }}</pre>
</div>
<button
v-if="logs.length > 0"
@click="clearLogs"
class="mt-2 px-3 py-1 rounded text-xs bg-monokai-comment/30 text-monokai-comment hover:bg-monokai-comment/50 transition-colors"
>
Effacer les logs
</button>
</div>
</div>
</div>
<!-- Modal Paramètres -->
<SettingsModal
:isOpen="showSettings"
@close="showSettings = false"
@configReloaded="handleConfigReloaded"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, 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 axios from 'axios'
const router = useRouter()
const ipStore = useIPStore()
const showSettings = ref(false)
// --- Données ---
const services = ref([])
const selectedPorts = ref(new Set())
const results = ref([])
const isScanning = ref(false)
const scanProgress = ref({ current: 0, total: 0, ip: '' })
const logs = ref([])
const filterText = ref('')
const sortColumn = ref('ip')
const sortAsc = ref(true)
// --- Panneau droit redimensionnable ---
const rightPanelWidth = ref(384)
const minPanelWidth = 288
const maxPanelWidth = 576
let isResizing = false
let startX = 0
let startWidth = 0
// --- Computed ---
const selectedCount = computed(() => selectedPorts.value.size)
const progressPercent = computed(() => {
if (scanProgress.value.total === 0) return 0
return Math.round((scanProgress.value.current / scanProgress.value.total) * 100)
})
const scanTimeout = ref(1.0)
const estimatedTime = computed(() => {
const total = scanProgress.value.total
const remaining = total - scanProgress.value.current
const seconds = Math.ceil(remaining * scanTimeout.value)
if (seconds < 60) return `${seconds}s`
const min = Math.floor(seconds / 60)
const sec = seconds % 60
return sec > 0 ? `${min}min ${sec}s` : `${min}min`
})
const filteredResults = computed(() => {
let data = [...results.value]
// Filtre texte
if (filterText.value) {
const search = filterText.value.toLowerCase()
data = data.filter(r =>
r.ip.includes(search) ||
(r.name || '').toLowerCase().includes(search) ||
(r.hostname || '').toLowerCase().includes(search) ||
r.service_name.toLowerCase().includes(search) ||
String(r.port).includes(search) ||
(r.protocol || '').toLowerCase().includes(search)
)
}
// Tri
data.sort((a, b) => {
let va = a[sortColumn.value]
let vb = b[sortColumn.value]
// Tri numérique pour IP
if (sortColumn.value === 'ip') {
const toNum = ip => ip.split('.').reduce((acc, oct) => acc * 256 + parseInt(oct), 0)
va = toNum(va)
vb = toNum(vb)
}
// Tri numérique pour port
if (sortColumn.value === 'port') {
va = Number(va)
vb = Number(vb)
}
if (va < vb) return sortAsc.value ? -1 : 1
if (va > vb) return sortAsc.value ? 1 : -1
// Tri secondaire : par port si même IP, par IP si même colonne
if (sortColumn.value === 'ip') return a.port - b.port
const toNum = ip => ip.split('.').reduce((acc, oct) => acc * 256 + parseInt(oct), 0)
return toNum(a.ip) - toNum(b.ip)
})
return data
})
// --- Méthodes ---
function isSelected(service) {
return selectedPorts.value.has(service.port)
}
function toggleService(service) {
const ports = new Set(selectedPorts.value)
if (ports.has(service.port)) {
ports.delete(service.port)
} else {
ports.add(service.port)
}
selectedPorts.value = ports
}
function selectAll() {
selectedPorts.value = new Set(services.value.map(s => s.port))
}
function selectNone() {
selectedPorts.value = new Set()
}
function sortBy(column) {
if (sortColumn.value === column) {
sortAsc.value = !sortAsc.value
} else {
sortColumn.value = column
sortAsc.value = true
}
}
function sortIcon(column) {
if (sortColumn.value !== column) return ''
return sortAsc.value ? '▲' : '▼'
}
function serviceColor(protocol) {
if (!protocol) return 'bg-monokai-comment/30 text-monokai-comment'
switch (protocol) {
case 'http':
case 'https':
return 'bg-monokai-green/20 text-monokai-green'
case 'ssh':
return 'bg-monokai-cyan/20 text-monokai-cyan'
case 'rdp':
return 'bg-monokai-orange/20 text-monokai-orange'
case 'smb':
case 'ftp':
return 'bg-monokai-yellow/20 text-monokai-yellow'
default:
return 'bg-monokai-purple/20 text-monokai-purple'
}
}
async function loadServices() {
try {
const response = await axios.get('/api/services/list')
services.value = response.data.services || []
} catch (error) {
console.error('Erreur chargement services:', error)
addLog('Erreur chargement de la liste des services')
}
}
function addLog(message) {
const now = new Date().toLocaleTimeString('fr-FR')
logs.value.push(`[${now}] ${message}`)
}
function clearLogs() {
logs.value = []
}
async function launchScan() {
if (selectedCount.value === 0 || isScanning.value) return
isScanning.value = true
results.value = []
scanProgress.value = { current: 0, total: 0, ip: '' }
const ports = Array.from(selectedPorts.value)
const serviceNames = ports.map(p => {
const svc = services.value.find(s => s.port === p)
return svc ? svc.name : `Port ${p}`
})
addLog(`Scan lancé pour ${ports.length} service(s) : ${serviceNames.join(', ')}`)
try {
const response = await axios.post('/api/services/scan', { ports })
if (response.data.status === 'error') {
addLog(`Erreur : ${response.data.message}`)
isScanning.value = false
}
// Les résultats arrivent via WebSocket (service_scan_complete)
} catch (error) {
console.error('Erreur scan services:', error)
addLog(`Erreur : ${error.response?.data?.detail || error.message}`)
isScanning.value = false
}
}
// --- WebSocket : écouter la progression du scan ---
function handleWsMessage(event) {
try {
const data = JSON.parse(event.data)
if (data.type === 'service_scan_progress') {
scanProgress.value = {
current: data.current || 0,
total: data.total || 0,
ip: data.ip || ''
}
} else if (data.type === 'service_scan_start') {
addLog(data.message || 'Scan de services démarré')
} else if (data.type === 'service_scan_log') {
addLog(data.message || '')
} else if (data.type === 'service_scan_result') {
// Résultat individuel en temps réel
if (data.result) {
results.value.push(data.result)
}
} else if (data.type === 'service_scan_complete') {
addLog(data.message || 'Scan de services terminé')
// Mettre à jour avec les résultats finaux (triés)
if (data.results) {
results.value = data.results
}
isScanning.value = false
scanProgress.value = { current: 0, total: 0, ip: '' }
}
} catch {
// Message non-JSON, ignorer
}
}
// --- Navigation ---
function goToDashboard() {
router.push('/')
}
function openSettings() {
showSettings.value = true
}
async function handleConfigReloaded() {
await ipStore.fetchIPs()
ipStore.bumpConfigReload()
await loadServices()
}
// --- Redimensionnement panneau droit ---
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)
}
// --- Lifecycle ---
onMounted(async () => {
await loadServices()
// Écouter les messages WebSocket pour la progression
if (ipStore.ws) {
ipStore.ws.addEventListener('message', handleWsMessage)
}
})
onBeforeUnmount(() => {
stopResize()
if (ipStore.ws) {
ipStore.ws.removeEventListener('message', handleWsMessage)
}
})
</script>