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
+221
View File
@@ -0,0 +1,221 @@
<template>
<div class="page">
<div class="page-header">
<h1>{{ t('networks') }}</h1>
<button class="btn-primary" @click="openAdd">{{ t('addNetwork') }}</button>
</div>
<div v-if="props.vlans.length === 0" class="empty">
{{ t('noNetworksConfigured') }}
</div>
<div class="table-wrap" v-else>
<table>
<thead>
<tr>
<th>{{ t('colType') }}</th>
<th>{{ t('colName') }}</th>
<th>{{ t('colSubnet') }}</th>
<th>{{ t('colColor') }}</th>
<th>{{ t('colActions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="vlan in props.vlans" :key="vlan.id">
<td>
<span class="badge-vlan" :style="{ background: vlan.color }">
{{ vlan.vlan_id != null ? vlan.vlan_id : 'LAN' }}
</span>
</td>
<td>{{ vlan.name }}</td>
<td><code>{{ vlan.cidr || '—' }}</code></td>
<td>
<div class="color-preview" :style="{ background: vlan.color }"></div>
</td>
<td class="actions">
<button class="btn-icon" @click="openEdit(vlan)" title="✎"></button>
<button class="btn-icon danger" @click="remove(vlan)" title="✕"></button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="showForm" class="modal-overlay" @click.self="showForm = false">
<div class="modal">
<h2>{{ editing ? t('editNetwork') : t('newNetwork') }}</h2>
<form @submit.prevent="save">
<div class="field">
<label>{{ t('vlanId') }} <span class="optional">{{ t('vlanIdHint') }}</span></label>
<input v-model="form.vlan_id" type="number" placeholder="ex: 10" />
</div>
<div class="field">
<label>{{ t('fieldName') }}</label>
<input v-model="form.name" type="text" required placeholder="ex: Serveurs" />
</div>
<div class="field">
<label>{{ t('subnet') }}</label>
<input v-model="form.cidr" type="text" :placeholder="t('subnetPlaceholder')" />
</div>
<div class="field">
<label>{{ t('color') }}</label>
<div class="color-row">
<input v-model="form.color" type="color" />
<span>{{ form.color }}</span>
<div class="color-presets">
<div
v-for="c in presetColors"
:key="c"
class="preset"
:style="{ background: c }"
@click="form.color = c"
></div>
</div>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" @click="showForm = false">{{ t('cancel') }}</button>
<button type="submit" class="btn-primary">{{ editing ? t('save') : t('create') }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { vlansApi } from '../api.js'
import { t, tFmt } from '../i18n.js'
const props = defineProps({ vlans: Array })
const emit = defineEmits(['refresh'])
const showForm = ref(false)
const editing = ref(null)
const form = reactive({ vlan_id: '', name: '', cidr: '', color: '#4A90D9' })
const presetColors = [
'#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
'#EC4899', '#14B8A6', '#F97316', '#6366F1', '#84CC16'
]
function openAdd() {
editing.value = null
Object.assign(form, { vlan_id: '', name: '', cidr: '', color: '#4A90D9' })
showForm.value = true
}
function openEdit(vlan) {
editing.value = vlan
Object.assign(form, {
vlan_id: vlan.vlan_id != null ? String(vlan.vlan_id) : '',
name: vlan.name,
cidr: vlan.cidr || '',
color: vlan.color
})
showForm.value = true
}
async function save() {
const payload = {
...form,
vlan_id: form.vlan_id !== '' ? Number(form.vlan_id) : null
}
try {
if (editing.value) {
await vlansApi.update(editing.value.id, payload)
} else {
await vlansApi.create(payload)
}
showForm.value = false
emit('refresh')
} catch (e) {
alert(e.response?.data?.detail || t('saveError'))
}
}
async function remove(vlan) {
const label = vlan.vlan_id != null ? `VLAN ${vlan.vlan_id}${vlan.name}` : `LAN ${vlan.name}`
if (!confirm(`Supprimer ${tFmt('confirmDeleteNetwork', label)}`)) return
try {
await vlansApi.remove(vlan.id)
emit('refresh')
} catch (e) {
alert(e.response?.data?.detail || t('deleteError'))
}
}
</script>
<style scoped>
.page { height: 100vh; overflow-y: auto; background: var(--bg-page); padding: 32px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
h1 { font-size: 22px; font-weight: 700; color: var(--text-primary); }
.btn-primary {
padding: 9px 18px; background: #3B82F6; color: #fff;
border: none; border-radius: 8px; font-size: 14px; font-weight: 600; transition: background 0.15s;
}
.btn-primary:hover { background: #2563EB; }
.btn-secondary {
padding: 9px 18px; background: var(--border); color: var(--text-secondary);
border: none; border-radius: 8px; font-size: 14px; font-weight: 500;
}
.btn-secondary:hover { background: var(--border-strong); }
.empty { padding: 48px; text-align: center; color: var(--text-faint); font-size: 15px; }
.table-wrap { background: var(--bg-card); border-radius: 12px; box-shadow: var(--shadow-card); overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
thead { background: var(--bg-thead); }
th { padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
td { padding: 14px 16px; font-size: 14px; color: var(--text-primary); border-top: 1px solid var(--border); }
tr:hover td { background: var(--bg-card-hover); }
.badge-vlan {
display: inline-flex; align-items: center; justify-content: center;
min-width: 36px; padding: 2px 8px; border-radius: 20px;
color: #fff; font-weight: 700; font-size: 13px;
}
code { background: var(--bg-page); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: var(--text-secondary); }
.color-preview { width: 24px; height: 24px; border-radius: 6px; border: 2px solid rgba(0,0,0,0.1); }
.actions { display: flex; gap: 8px; }
.btn-icon {
width: 30px; height: 30px; border: none; border-radius: 6px;
background: var(--bg-chip); color: var(--text-secondary); font-size: 15px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.btn-icon:hover { background: var(--border-strong); color: var(--text-primary); }
.btn-icon.danger:hover { background: rgba(239,68,68,0.15); color: #EF4444; }
.modal-overlay {
position: fixed; inset: 0; background: var(--modal-overlay);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal {
background: var(--bg-card); border-radius: 16px; padding: 28px;
width: 440px; max-width: 95vw; box-shadow: var(--shadow-modal);
}
.modal h2 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 20px; }
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.optional { font-weight: 400; color: var(--text-faint); font-size: 12px; }
.field input[type="text"],
.field input[type="number"] {
width: 100%; padding: 9px 12px; border: 1.5px solid var(--border);
border-radius: 8px; font-size: 14px; color: var(--text-primary); background: var(--bg-input);
transition: border-color 0.15s;
}
.field input:focus { outline: none; border-color: #3B82F6; }
.color-row { display: flex; align-items: center; gap: 12px; }
.color-row input[type="color"] { width: 40px; height: 36px; border: none; border-radius: 6px; cursor: pointer; padding: 0; }
.color-row span { font-size: 13px; color: var(--text-muted); font-family: monospace; }
.color-presets { display: flex; gap: 6px; flex-wrap: wrap; }
.preset { width: 20px; height: 20px; border-radius: 4px; cursor: pointer; transition: transform 0.1s; }
.preset:hover { transform: scale(1.2); }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; }
</style>