Initial commit — Stupid Simple Network Inventory

Application web d'inventaire réseau manuel avec FastAPI, Vue 3 et Docker.
Inclut l'authentification JWT, la découverte ICMP, et la topologie en cards CSS.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 09:19:19 +02:00
commit 88cf6458d0
58 changed files with 10365 additions and 0 deletions
+518
View File
@@ -0,0 +1,518 @@
<template>
<div class="topology-page">
<!-- Barre d'outils -->
<div class="topo-toolbar">
<button class="ping-btn" :class="{ pinging }" :disabled="pinging" @click="pingAll">
<span class="ping-btn-dot" :class="pingBtnDotClass"></span>
{{ pinging ? t('pinging') : pingDone ? t('refreshPing') : t('ping') }}
</button>
<span v-if="pingDone && !pinging" class="ping-summary">
{{ pingUpCount }} ↑ &nbsp; {{ pingDownCount }} ↓
</span>
</div>
<!-- Cartes spéciales en haut : WAN et Passerelle -->
<div v-if="liveboxDevices.length || gatewayDevices.length" class="special-row">
<div v-if="liveboxDevices.length" class="special-card special-wan">
<div class="special-header">
<span class="special-icon">🌐</span>
<span class="special-title">{{ t('wan') }}</span>
</div>
<div class="special-body">
<div v-for="d in liveboxDevices" :key="d.id" class="device-chip chip-livebox">
<span class="chip-icon"><DeviceIcon :device-type="d.type" :is-livebox="d.is_livebox" :is-gateway="d.is_gateway" :name="d.name" :description="d.description" :size="16" type-only /></span>
<div class="chip-body">
<span class="chip-name">{{ d.name }}</span>
<div class="chip-sub">
<span v-for="iface in d.interfaces" :key="iface.id">
<code v-if="iface.ip_address" class="chip-ip">{{ iface.ip_address }}</code>
</span>
<svg v-for="b in detectBrands(d.name, d.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
</div>
</div>
<a v-if="d.url" :href="d.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
<span v-if="pingStatus[d.id]" class="ping-dot" :class="'ping-' + pingStatus[d.id]" :title="pingStatus[d.id] === 'up' ? t('reachable') : t('unreachable')"></span>
</div>
</div>
</div>
<div v-if="gatewayDevices.length" class="special-card special-gw">
<div class="special-header">
<span class="special-icon">⬡</span>
<span class="special-title">{{ t('gateway') }}</span>
</div>
<div class="special-body">
<div v-for="d in gatewayDevices" :key="d.id" class="device-chip chip-gateway">
<span class="chip-icon"><DeviceIcon :device-type="d.type" :is-livebox="d.is_livebox" :is-gateway="d.is_gateway" :name="d.name" :description="d.description" :size="16" type-only /></span>
<div class="chip-body">
<span class="chip-name">{{ d.name }}</span>
<div class="chip-sub">
<span v-if="d.description" class="chip-iface">{{ d.description }}</span>
<svg v-for="b in detectBrands(d.name, d.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
</div>
</div>
<a v-if="d.url" :href="d.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
<span v-if="pingStatus[d.id]" class="ping-dot" :class="'ping-' + pingStatus[d.id]" :title="pingStatus[d.id] === 'up' ? t('reachable') : t('unreachable')"></span>
</div>
</div>
</div>
</div>
<!-- Grille des réseaux -->
<div class="vlan-grid">
<div
v-for="vlan in props.vlans"
:key="vlan.id"
class="vlan-card"
:style="{ '--vlan-color': vlan.color }"
>
<div class="vlan-header">
<div class="vlan-id-badge">{{ vlan.vlan_id != null ? 'VLAN ' + vlan.vlan_id : 'LAN' }}</div>
<div class="vlan-meta">
<span class="vlan-name">{{ vlan.name }}</span>
<code v-if="vlan.cidr" class="vlan-cidr">{{ vlan.cidr }}</code>
</div>
<span class="vlan-count">{{ vlanMembers(vlan.id).length }}</span>
</div>
<div class="vlan-body">
<div v-if="vlanMembers(vlan.id).length === 0" class="empty-vlan">
{{ t('noDevice') }}
</div>
<div
v-for="item in vlanMembers(vlan.id)"
:key="item.device.id"
class="device-chip"
:class="'chip-' + chipRole(item.device)"
>
<span class="chip-icon">
<DeviceIcon :device-type="item.device.type" :is-livebox="item.device.is_livebox" :is-gateway="item.device.is_gateway" :name="item.device.name" :description="item.device.description" :size="16" type-only />
</span>
<div class="chip-body">
<span class="chip-name">{{ item.device.name }}</span>
<div class="chip-sub">
<code v-if="item.ip" class="chip-ip">{{ item.ip }}</code>
<span v-if="item.ifaceName" class="chip-iface">{{ item.ifaceName }}</span>
<svg v-for="b in detectBrands(item.device.name, item.device.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
</div>
</div>
<div class="chip-tags">
<span v-if="pingStatus[item.device.id]" class="ping-dot" :class="'ping-' + pingStatus[item.device.id]" :title="pingStatus[item.device.id] === 'up' ? t('reachable') : t('unreachable')"></span>
<a v-if="item.device.url" :href="item.device.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
<span v-if="item.device.is_gateway" class="chip-tag tag-gw">GW</span>
<span v-if="item.device.virt_type === 'lxc'" class="chip-tag tag-lxc">LXC</span>
<span v-if="item.device.virt_type === 'qemu'" class="chip-tag tag-vm">VM</span>
</div>
</div>
</div>
</div>
<!-- Zone non assigné -->
<div
v-if="untaggedDevices.length"
class="vlan-card untagged"
>
<div class="vlan-header">
<div class="vlan-id-badge untagged-badge">—</div>
<div class="vlan-meta">
<span class="vlan-name">{{ t('unassigned') }}</span>
</div>
<span class="vlan-count">{{ untaggedDevices.length }}</span>
</div>
<div class="vlan-body">
<div
v-for="d in untaggedDevices"
:key="d.id"
class="device-chip"
:class="'chip-' + chipRole(d)"
>
<span class="chip-icon">
<DeviceIcon :device-type="d.type" :is-livebox="d.is_livebox" :is-gateway="d.is_gateway" :name="d.name" :description="d.description" :size="16" type-only />
</span>
<div class="chip-body">
<span class="chip-name">{{ d.name }}</span>
<div class="chip-sub">
<span v-if="d.description" class="chip-iface">{{ d.description }}</span>
<svg v-for="b in detectBrands(d.name, d.description)" :key="b.title" width="11" height="11" viewBox="0 0 24 24" fill="currentColor" class="chip-brand-icon" :title="b.title" :style="{ color: '#' + b.hex }"><path :d="b.path" /></svg>
</div>
</div>
<a v-if="d.url" :href="d.url" target="_blank" rel="noreferrer noopener" class="chip-tag tag-link" :title="t('openWebUI')">Link</a>
<span v-if="pingStatus[d.id]" class="ping-dot" :class="'ping-' + pingStatus[d.id]" :title="pingStatus[d.id] === 'up' ? t('reachable') : t('unreachable')"></span>
</div>
</div>
</div>
</div>
<div v-if="props.vlans.length === 0 && props.devices.length === 0" class="empty-state">
<div class="empty-icon">⬡</div>
<p>{{ t('noDevices') }}</p>
<p class="empty-hint">{{ t('noDevicesHint') }}</p>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import DeviceIcon from './DeviceIcon.vue'
import { detectBrands } from '../brandIcons.js'
import { discoveryApi } from '../api.js'
import { t } from '../i18n.js'
function ipToInt(ip) {
if (!ip) return Infinity
const p = ip.split('.').map(Number)
return p.length === 4 ? (p[0] << 24 | p[1] << 16 | p[2] << 8 | p[3]) >>> 0 : Infinity
}
const props = defineProps({
devices: { type: Array, default: () => [] },
vlans: { type: Array, default: () => [] },
})
const liveboxDevices = computed(() => props.devices.filter(d => d.is_livebox))
const gatewayDevices = computed(() => props.devices.filter(d => d.is_gateway))
function vlanMembers(vlanId) {
const result = []
for (const device of props.devices) {
const iface = device.interfaces.find(i => i.vlan_id === vlanId)
if (iface) {
result.push({
device,
ip: iface.ip_address || '',
ifaceName: iface.name || '',
})
}
}
return result.sort((a, b) => ipToInt(a.ip) - ipToInt(b.ip))
}
const untaggedDevices = computed(() =>
props.devices.filter(d => {
if (d.is_livebox || d.is_gateway) return false
return !d.interfaces.some(i => i.vlan_id)
}).sort((a, b) => ipToInt(a.interfaces[0]?.ip_address) - ipToInt(b.interfaces[0]?.ip_address))
)
function chipRole(device) {
if (device.is_livebox) return 'livebox'
if (device.is_gateway) return 'gateway'
return device.type || 'other'
}
// ── Ping ──────────────────────────────────────────────────────────────────────
const pingStatus = ref({})
const pinging = ref(false)
const pingDone = ref(false)
const pingUpCount = computed(() => Object.values(pingStatus.value).filter(s => s === 'up').length)
const pingDownCount = computed(() => Object.values(pingStatus.value).filter(s => s === 'down').length)
const pingBtnDotClass = computed(() => {
if (pinging.value) return 'dot-pinging'
if (!pingDone.value) return 'dot-idle'
return pingDownCount.value > 0 ? 'dot-warn' : 'dot-ok'
})
async function pingAll() {
const ipToDevices = {}
for (const d of props.devices) {
for (const i of d.interfaces) {
if (i.ip_address) {
if (!ipToDevices[i.ip_address]) ipToDevices[i.ip_address] = []
ipToDevices[i.ip_address].push(d.id)
}
}
}
const ips = Object.keys(ipToDevices)
if (!ips.length) return
pinging.value = true
try {
const { data } = await discoveryApi.ping(ips)
const next = {}
for (const { ip, alive } of data) {
for (const id of (ipToDevices[ip] || [])) {
if (alive || next[id] !== 'up') next[id] = alive ? 'up' : 'down'
}
}
pingStatus.value = next
pingDone.value = true
} finally {
pinging.value = false
}
}
</script>
<style scoped>
.topology-page {
height: 100vh;
overflow-y: auto;
background: var(--bg-page);
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
.topo-toolbar {
display: flex; align-items: center; gap: 10px;
}
.ping-btn {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 14px; height: 30px;
background: var(--bg-card); border: 1.5px solid var(--border);
border-radius: 8px; font-size: 12px; font-weight: 600; color: var(--text-secondary);
cursor: pointer;
}
.ping-btn:hover:not(:disabled) { border-color: var(--border-strong); background: var(--bg-card-hover); }
.ping-btn:disabled { opacity: 0.6; cursor: default; }
.ping-btn.pinging { border-color: #93C5FD; color: #1D4ED8; }
.ping-btn-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.dot-idle { background: #CBD5E1; }
.dot-pinging { background: #60A5FA; animation: pulse 1s infinite; }
.dot-ok { background: #22C55E; }
.dot-warn { background: #F97316; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.ping-summary { font-size: 12px; color: var(--text-muted); }
/* ── Dot ping ────────────────────────────────────────────────────────────── */
.ping-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.ping-up { background: #22C55E; }
.ping-down { background: #EF4444; }
/* ── Cartes spéciales (WAN + Passerelle) ─────────────────────────────────── */
.special-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.special-card {
background: var(--bg-card);
border-radius: 12px;
border: 1.5px solid;
overflow: hidden;
box-shadow: var(--shadow-card);
min-width: 220px;
flex: 1;
}
.special-wan { border-color: #FCA5A5; }
.special-gw { border-color: #FCD34D; }
.special-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.special-wan .special-header { background: #FEF2F2; color: #B91C1C; border-bottom: 1px solid #FECACA; }
.special-gw .special-header { background: #FFFBEB; color: #92400E; border-bottom: 1px solid #FDE68A; }
:global(html.dark .special-wan) { border-color: #7F1D1D; }
:global(html.dark .special-gw) { border-color: #78350F; }
:global(html.dark .special-wan .special-header) { background: #2A1515; color: #FCA5A5; border-bottom-color: #3D1F1F; }
:global(html.dark .special-gw .special-header) { background: #2A200A; color: #FCD34D; border-bottom-color: #3D3010; }
.special-body {
padding: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* ── Grille des réseaux ──────────────────────────────────────────────────── */
.vlan-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
/* ── Card réseau ─────────────────────────────────────────────────────────── */
.vlan-card {
background: var(--bg-card);
border-radius: 12px;
border: 1.5px solid var(--border);
border-top: 3px solid var(--vlan-color, #CBD5E1);
overflow: hidden;
box-shadow: var(--shadow-card);
}
.vlan-card.untagged {
--vlan-color: #CBD5E1;
border-style: dashed;
border-top-style: solid;
}
.vlan-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--bg-thead);
border-bottom: 1px solid var(--border);
}
.vlan-id-badge {
background: var(--vlan-color, #CBD5E1);
color: #fff;
font-size: 11px;
font-weight: 800;
padding: 2px 9px;
border-radius: 20px;
white-space: nowrap;
letter-spacing: 0.03em;
}
.untagged-badge { background: #94A3B8; }
.vlan-meta {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
}
.vlan-name {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vlan-cidr {
font-size: 11px;
color: var(--text-faint);
font-family: monospace;
white-space: nowrap;
}
.vlan-count {
font-size: 13px;
font-weight: 700;
color: var(--vlan-color, #CBD5E1);
background: color-mix(in srgb, var(--vlan-color) 10%, transparent);
border-radius: 20px;
padding: 1px 8px;
min-width: 24px;
text-align: center;
}
.vlan-body {
padding: 10px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 6px;
}
@media (max-width: 600px) {
.vlan-body { grid-template-columns: 1fr; }
}
.empty-vlan {
font-size: 12px;
color: var(--text-faint);
text-align: center;
padding: 16px 0;
font-style: italic;
grid-column: 1 / -1;
}
/* ── Device chip ─────────────────────────────────────────────────────────── */
.device-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border-radius: 8px;
background: var(--bg-chip);
border: 1px solid var(--border);
transition: background 0.12s, box-shadow 0.12s;
}
.device-chip:hover {
background: var(--bg-chip-hover);
box-shadow: var(--shadow-card);
}
.chip-icon {
width: 28px; height: 28px;
border-radius: 7px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
}
.chip-server .chip-icon { background: #DBEAFE; color: #1D4ED8; }
.chip-nas .chip-icon { background: #EDE9FE; color: #6D28D9; }
.chip-switch .chip-icon { background: #D1FAE5; color: #065F46; }
.chip-router .chip-icon { background: #FFEDD5; color: #C2410C; }
.chip-access_point .chip-icon { background: #CCFBF1; color: #0F766E; }
.chip-gateway .chip-icon { background: #FEF3C7; color: #B45309; }
.chip-livebox .chip-icon { background: #FEE2E2; color: #B91C1C; }
.chip-camera .chip-icon { background: #E0F2FE; color: #0369A1; }
.chip-temperature .chip-icon { background: #F0F9FF; color: #0284C7; }
.chip-sensor .chip-icon { background: #ECFCCB; color: #3F6212; }
.chip-hub .chip-icon { background: #EEF2FF; color: #3730A3; }
.chip-smart_plug .chip-icon { background: #FFF7ED; color: #C2410C; }
.chip-alarm .chip-icon { background: #FEF2F2; color: #B91C1C; }
.chip-light .chip-icon { background: #FEFCE8; color: #854D0E; }
.chip-doorbell .chip-icon { background: #FDF4FF; color: #7E22CE; }
.chip-desktop .chip-icon { background: #E0E7FF; color: #4338CA; }
.chip-laptop .chip-icon { background: #DCFCE7; color: #15803D; }
.chip-other .chip-icon { background: #F1F5F9; color: #64748B; }
.chip-body { flex: 1; min-width: 0; }
.chip-name {
font-size: 12px; font-weight: 600; color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block;
}
.chip-sub {
display: flex; align-items: center; gap: 5px; margin-top: 1px; flex-wrap: wrap;
}
.chip-brand-icon { display: inline-block; flex-shrink: 0; opacity: 0.85; }
.chip-ip {
font-size: 11px; color: var(--chip-ip-color);
font-family: monospace; background: var(--chip-ip-bg);
padding: 0px 4px; border-radius: 3px;
}
.chip-iface {
font-size: 10px; color: var(--text-faint);
font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.chip-tags { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; }
.chip-tag {
font-size: 9px; font-weight: 800; padding: 1px 5px;
border-radius: 4px; text-align: center; letter-spacing: 0.03em;
}
.tag-gw { background: #FEF3C7; color: #B45309; }
.tag-lxc { background: #DBEAFE; color: #1D4ED8; }
.tag-vm { background: #EDE9FE; color: #6D28D9; }
.tag-link { background: #F0FDF4; color: #15803D; text-decoration: none; }
.tag-link:hover { background: #DCFCE7; }
/* ── Empty state ─────────────────────────────────────────────────────────── */
.empty-state {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; color: var(--text-faint);
}
.empty-icon { font-size: 64px; margin-bottom: 16px; opacity: 0.3; }
.empty-state p { font-size: 16px; margin-bottom: 6px; }
.empty-hint { font-size: 13px; color: var(--text-faint); }
</style>