commit 88cf6458d02b465b99497b4334964a94d4ba8048 Author: Olivier Date: Sun May 17 09:19:19 2026 +0200 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fa58bbb --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# Stupid Simple Network Inventory — environment variables +# Copy this file to .env and fill in the values. +# NEVER commit .env to version control. + +# ── JWT Secret ───────────────────────────────────────────────────────���────── +# Required in production. If unset, a random key is auto-generated and stored +# in db_data/secret_key.txt (0600 permissions). All sessions are invalidated +# when this key changes (key rotation). +# +# Generate a strong secret: +# python3 -c "import secrets; print(secrets.token_hex(32))" +# Or use a Docker secret (recommended for production). +SECRET_KEY= + +# ── Initial admin password ────────────────────────────────────────────────── +# Set this before the first run to bypass the admin/admin bootstrap. +# When set: admin is created with this password and must_change_password=0. +# When unset: admin is created with password "admin" and must_change_password=1 +# (forced password change on first login). +# +# This variable is only read when the users table is empty (first run). +# It has no effect on subsequent starts. +INITIAL_ADMIN_PASSWORD= + +# ── CORS allowed origins ───────────────────────────────────────────────────── +# Comma-separated list of allowed origins, or "*" for all (default). +# The app is designed for same-origin access via the Nginx reverse proxy. +# Restrict this if you expose the API to multiple origins. +# +# Examples: +# ALLOWED_ORIGINS=* (default — permissive) +# ALLOWED_ORIGINS=https://inventory.example.com +# ALLOWED_ORIGINS=https://a.example.com,https://b.example.com +# ALLOWED_ORIGINS= (empty — disables CORS headers) +ALLOWED_ORIGINS=* + +# ── Container user IDs ─────────────────────────────────────────────────────── +# UID and GID used to run the backend process inside the container. +# Must match the host user owning ./db_data/ to allow read/write on the +# bind-mounted volume without root privileges. +# +# Get your values: id -u && id -g +# Then create the data directory before the first run: +# mkdir -p db_data +# +DOCKER_UID=1000 +DOCKER_GID=1000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78f2f4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# ── Secrets & données runtime ───────────────────────────────────────────────── +# Ne JAMAIS committer : clés, mots de passe, base de données, tokens +.env +.env.local +.env.*.local +data/ +db_data/ +*.db +*.db-journal +*.db-shm +*.db-wal +secret_key.txt + +# ── Outils IA / sessions de développement ───────────────────────────────────── +# Contiennent des IDs de session, permissions locales — pas de valeur pour le dépôt +.claude/ +.agents/ +.codex/ +claude-session.txt +.aider* + +# ── Python ──────────────────────────────────────────────────────────────────── +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ +venv/ +.venv/ +env/ +pip-wheel-metadata/ + +# ── Node / Frontend ─────────────────────────────────────────────────────────── +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +*.tsbuildinfo + +# ── Logs ───────────────────────────────────────────────────────────────────── +*.log +logs/ +npm-debug.log* + +# ── Certificats & clés PKI ─────────────────────────────────────────────────── +*.pem +*.key +*.crt +*.p12 +*.pfx +*.cer + +# ── IDE & éditeurs ──────────────────────────────────────────────────────────── +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.classpath + +# ── OS ──────────────────────────────────────────────────────────────────────── +.DS_Store +.DS_Store? +._* +Thumbs.db +Desktop.ini diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..549742c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,207 @@ +# Stupid Simple Network Inventory — Agent Notes + +## Project Overview + +Web application for manual server/network inventory and logical network topology display. + +## Stack + +- Backend: FastAPI, Python 3.11, SQLAlchemy, SQLite +- Frontend: Vue 3 + Vite +- Topology view: CSS cards, no Cytoscape +- Icons: `lucide-vue-next` for generic UI, `simple-icons` for brand logos +- Reverse proxy: Nginx serves the frontend and proxies `/api/` to the backend +- Runtime: Docker Compose + +## Run + +```bash +docker compose up --build -d +``` + +Application URL: http://localhost:8080 + +## Architecture + +- `backend/main.py`: FastAPI entry point + startup SQLite migrations (run before `create_all`) +- `backend/database.py`: SQLAlchemy engine and sessions +- `backend/models.py`: ORM models (User, Vlan, Device, DeviceInterface) +- `backend/routers/auth.py`: JWT login, account update, token refresh — exports `get_current_user` for main.py +- `backend/routers/vlans.py`: network CRUD +- `backend/routers/devices.py`: device CRUD — assign fields explicitly, do not use `model_dump()` on Device +- `backend/routers/discovery.py`: ICMP ping sweep + DNS PTR (`/scan`) + parallel ping (`/ping`) +- `frontend/src/App.vue`: global layout and state + auth guard (shows LoginPage if not authenticated) +- `frontend/src/api.js`: axios API calls (`vlansApi`, `devicesApi`, `discoveryApi`, `authApi`) +- `frontend/src/auth.js`: reactive auth state (`isAuthenticated`, `currentUsername`, `setAuth`, `clearAuth`, `getToken`) — persisted in localStorage +- `frontend/src/i18n.js`: i18n — `locale` (ref), `t(key)`, `tFmt(key, ...args)`, `setLocale(lang)` +- `frontend/src/theme.js`: theme — `theme` (ref), `toggleTheme()`, applies/removes `html.dark` class +- `frontend/src/brandIcons.js`: BRANDS array + `detectBrands(name, description)` — shared utility +- `frontend/src/components/TopologyGraph.vue`: topology card layout, ping feature +- `frontend/src/components/DeviceIcon.vue`: Lucide type icon (prop `typeOnly`) or brand logos +- `frontend/src/components/DeviceManager.vue`: device CRUD + filter bar + search +- `frontend/src/components/VlanManager.vue`: network CRUD (LANs + VLANs) +- `frontend/src/components/DiscoveryModal.vue`: automatic discovery UI +- `frontend/src/components/LoginPage.vue`: full-screen login form, emits `@login` with `{ token, username }` +- `frontend/src/components/AccountModal.vue`: change username/password, emits `@updated` with new token + +## Data Model + +- `User`: `id`, `username` (unique), `hashed_password` (bcrypt), `must_change_password` (bool, default 0), `token_version` (int, default 1) +- `Vlan`: `id`, `vlan_id` (nullable int — null = plain LAN), `name`, `cidr`, `color` +- `Device`: `id`, `name`, `type`, `description`, `is_gateway`, `is_livebox`, `virt_type` (nullable), `url` (nullable) +- `DeviceInterface`: `id`, `device_id`, `vlan_id` (nullable FK), `ip_address`, `name`, `is_upstream` + +## Authentication + +JWT HS256, **24-hour expiry**. Payload: `{ sub: username, ver: token_version, exp: ... }`. Secret key persisted in `data/secret_key.txt` (0600 permissions) or `SECRET_KEY` env var. + +Default credentials: `admin` / `admin` — seeded with `must_change_password=1` by `_migrate_users()` if the `users` table is empty. Set `INITIAL_ADMIN_PASSWORD` env var to skip this bootstrap. + +**Token invalidation**: password change increments `User.token_version`. `get_current_user` rejects tokens whose `ver` doesn't match → old sessions expire immediately. + +**must_change_password**: when `True`, business routers return 403 `"Password change required"`. The frontend shows a forced `` that blocks the app. `_migrate_force_admin_password_change()` sets this flag at startup if admin's password is still the default. + +**bcrypt compatibility**: `passlib 1.7.4` is incompatible with `bcrypt >= 4.0`. `requirements.txt` pins `bcrypt==3.2.2`. Do not upgrade `bcrypt` without also upgrading `passlib`. + +Business route groups protected via `dependencies=[Depends(require_password_changed)]` (wraps `get_current_user`). Auth router has no protection. + +Frontend auth guard in `App.vue`: +- `!isAuthenticated` → `` +- `mustChangePassword` → `` +- else → full app + +Template root is `
` with `display: contents` — prevents Firefox autofill overlay `NotFoundError` on fragment comment nodes. + +## Device Types (18) + +`server`, `switch`, `router`, `nas`, `gateway`, `livebox`, `access_point`, `camera`, `temperature`, `sensor`, `hub`, `smart_plug`, `alarm`, `light`, `doorbell`, `desktop`, `laptop`, `other` + +## Device Fields + +### virt_type +Form label: "Type d'environnement d'exécution". Values: `null`, `baremetal`, `lxc`, `qemu`. + +### url +Optional web UI URL. Hidden in the form for `desktop` and `laptop` types. Displayed as a clickable link in the device card, and as a green "Link" tag on topology chips. + +When adding a new Device field: update `models.py`, add a startup migration in `main.py`, update `DeviceCreate` and `DeviceOut` in `routers/devices.py`, assign explicitly in `create_device` and `update_device`. + +## Networks (VlanManager) + +`vlan_id` is optional. Omitting it creates a plain LAN ("LAN" badge). Networks ordered: LANs first (NULL vlan_id), then VLANs by ascending ID. + +## Topology View + +`TopologyGraph.vue` uses CSS cards only. + +- Toolbar: Ping button with animated dot indicator + up/down count +- WAN card: red, `is_livebox` devices +- Gateway card: yellow, `is_gateway` devices +- Network list: one full-width card per network, stacked vertically (flex-col); a device appears in every card matching its interfaces +- Unassigned card: devices with no interface, not livebox, not gateway +- Devices sorted by IP ascending; no-IP devices go last + +### Card layout + +All network cards are full width, stacked vertically. Inside each card, chips are arranged in a horizontal grid (`grid auto-fill, minmax(210px, 1fr)`), wrapping as needed. Card height grows with the number of chips. Single column below 600px. + +### Device chip structure + +``` +[chip-icon (type, Lucide)] [chip-body] [chip-tags] + chip-name + chip-sub: IP + iface + brand SVGs (11px) + chip-tags: Link | GW | LXC | VM + ping dot +``` + +- `chip-icon`: always the Lucide type icon (`type-only` prop) +- Brand logos: small colored SVGs in `chip-sub`, no text label, `title` tooltip on hover +- `tag-link`: green, clickable ``, only if `device.url` is set +- Ping dot: green (`ping-up`) or red (`ping-down`), shown after a ping run + +## DeviceIcon.vue + +Prop `typeOnly` (bool, default false): +- `false`: show brand logos if detected, else Lucide — used in TopologyGraph chips +- `true`: always show Lucide type icon — used in DeviceManager cards + +## Brand Icons (brandIcons.js) + +Central `detectBrands(name, description)` returns all matching brand icon objects from `simple-icons`. + +To check if a brand exists: `node -e "const si = require('./node_modules/simple-icons'); console.log(Object.keys(si).filter(k => k.toLowerCase().includes('name')))"` + +To add a brand: import `si` in `brandIcons.js`, add entry to `BRANDS` array. + +Supported brands: Proxmox, Docker, Synology, TrueNAS, Ubiquiti/UniFi, MikroTik, Cisco, TP-Link, ASUS, Netgear, pfSense, OPNsense, OpenWrt, Apache/Apache2, Traefik, MariaDB, Kubernetes/k3s, Debian, Ubuntu, Ansible, Dell, HP, Raspberry Pi, Arduino, KDE/Plasma, Excalidraw, Nextcloud, Paperless-NGX, Uptime Kuma, MkDocs, Jellyfin, Home Assistant, Philips Hue, Xiaomi. + +## DeviceManager Filter Bar + +Compact 28px bar combining: +- Search input (name, description, IP — real-time, Escape to clear) +- Type dropdown (multi-select, only present types) +- Network dropdown (with color swatch) +- Brand dropdown (with SVG icon, sorted alphabetically) +- Virt dropdown (only if at least one device has a virt_type) + +All filters combine (AND between categories, OR within). "Clear" button + "X / total" counter when active. + +## Ping (discovery.py) + +`POST /api/discovery/ping` — parallel ICMP ping, 50 workers. +- Body: `{ ips: ["1.2.3.4", ...] }` +- Returns: `[{ ip, alive }, ...]` + +Device status: "up" if any of its IPs responds, "down" if all fail, no dot if no IPs. + +## SQLite Migrations (main.py) + +Idempotent startup migrations run before `create_all`: +- `_migrate_vlan_nullable()`: recreates `vlans` to allow NULL `vlan_id` +- `_migrate_device_virt_type()`: adds `virt_type VARCHAR` to `devices` +- `_migrate_device_url()`: adds `url VARCHAR` to `devices` +- `_migrate_users()`: creates the `users` table and seeds `admin`/`admin` if count is 0 + +Pattern for new nullable column: check if column exists via `PRAGMA table_info`, run `ALTER TABLE … ADD COLUMN` if missing. + +## Discovery + +`POST /api/discovery/scan`: ICMP ping sweep + DNS PTR lookup. Max 1024 hosts/network, 100 workers. Needs `cap_add: NET_RAW` (already configured). + +## Theme (theme.js) + +`theme.js` exports `theme` (ref, `'light'`/`'dark'`) and `toggleTheme()`. Persisted in `localStorage('theme')`. Applies/removes `html.dark` class on `document.documentElement`. + +CSS variables are defined in `App.vue` global ` + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..d65c10f --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,51 @@ +import axios from 'axios' +import { getToken, clearAuth } from './auth.js' + +const http = axios.create({ baseURL: '/api' }) + +http.interceptors.request.use(config => { + const token = getToken() + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) + +http.interceptors.response.use( + res => res, + err => { + if (err.response?.status === 401 && getToken()) { + clearAuth() + window.location.reload() + } + return Promise.reject(err) + } +) + +export const authApi = { + login: (username, password) => { + const form = new URLSearchParams() + form.append('username', username) + form.append('password', password) + return http.post('/auth/login', form) + }, + updateAccount: (data) => http.put('/auth/account', data), + me: () => http.get('/auth/me'), +} + +export const vlansApi = { + list: () => http.get('/vlans/'), + create: (data) => http.post('/vlans/', data), + update: (id, data) => http.put(`/vlans/${id}`, data), + remove: (id) => http.delete(`/vlans/${id}`), +} + +export const devicesApi = { + list: () => http.get('/devices/'), + create: (data) => http.post('/devices/', data), + update: (id, data) => http.put(`/devices/${id}`, data), + remove: (id) => http.delete(`/devices/${id}`), +} + +export const discoveryApi = { + scan: (data) => http.post('/discovery/scan', data), + ping: (ips) => http.post('/discovery/ping', { ips }), +} diff --git a/frontend/src/auth.js b/frontend/src/auth.js new file mode 100644 index 0000000..3d8a2c4 --- /dev/null +++ b/frontend/src/auth.js @@ -0,0 +1,31 @@ +import { ref, computed } from 'vue' + +const _token = ref(localStorage.getItem('auth_token') || null) +const _username = ref(localStorage.getItem('auth_username') || null) +const _mustChange = ref(localStorage.getItem('auth_mustchange') === 'true') + +export const isAuthenticated = computed(() => !!_token.value) +export const currentUsername = computed(() => _username.value) +export const mustChangePassword = computed(() => _mustChange.value) + +export function setAuth(token, username, mustChange = false) { + _token.value = token + _username.value = username + _mustChange.value = mustChange + localStorage.setItem('auth_token', token) + localStorage.setItem('auth_username', username) + localStorage.setItem('auth_mustchange', String(mustChange)) +} + +export function clearAuth() { + _token.value = null + _username.value = null + _mustChange.value = false + localStorage.removeItem('auth_token') + localStorage.removeItem('auth_username') + localStorage.removeItem('auth_mustchange') +} + +export function getToken() { + return _token.value +} diff --git a/frontend/src/brandIcons.js b/frontend/src/brandIcons.js new file mode 100644 index 0000000..3b3e83a --- /dev/null +++ b/frontend/src/brandIcons.js @@ -0,0 +1,87 @@ +import { + siProxmox, siDocker, + siSynology, siTruenas, + siUbiquiti, siMikrotik, siCisco, siTplink, siAsus, siNetgear, siPfsense, siOpnsense, siOpenwrt, + siApache, siTraefikproxy, + siMariadb, + siKubernetes, + siDebian, siUbuntu, + siAnsible, + siDell, siHp, + siRaspberrypi, siArduino, + siNextcloud, siPaperlessngx, siUptimekuma, siMaterialformkdocs, + siJellyfin, siHomeassistant, siPhilipshue, siXiaomi, + siExcalidraw, + siKde, +} from 'simple-icons' + +// Ordre : du plus spécifique au plus générique pour éviter les faux positifs. +const BRANDS = [ + // Hyperviseurs / virtualisation + { kw: ['proxmox', 'pve'], icon: siProxmox }, + { kw: ['docker'], icon: siDocker }, + + // NAS + { kw: ['synology', 'dsm'], icon: siSynology }, + { kw: ['truenas', 'freenas'], icon: siTruenas }, + + // Réseau + { kw: ['ubiquiti', 'unifi', 'usg', 'udm'], icon: siUbiquiti }, + { kw: ['mikrotik', 'routeros'], icon: siMikrotik }, + { kw: ['cisco'], icon: siCisco }, + { kw: ['tp-link', 'tplink', 'tp link'], icon: siTplink }, + { kw: ['asus'], icon: siAsus }, + { kw: ['netgear'], icon: siNetgear }, + { kw: ['pfsense'], icon: siPfsense }, + { kw: ['opnsense'], icon: siOpnsense }, + { kw: ['openwrt'], icon: siOpenwrt }, + + // Serveurs web / proxy + { kw: ['apache', 'apache2', 'httpd'], icon: siApache }, + { kw: ['traefik'], icon: siTraefikproxy }, + + // Bases de données + { kw: ['mariadb', 'maria db'], icon: siMariadb }, + + // Orchestration + { kw: ['kubernetes', 'k8s', 'kubectl', 'k3s'], icon: siKubernetes }, + + // OS / distros + { kw: ['debian'], icon: siDebian }, + { kw: ['ubuntu'], icon: siUbuntu }, + + // Automatisation + { kw: ['ansible'], icon: siAnsible }, + + // Serveurs + { kw: ['dell', 'idrac', 'poweredge'], icon: siDell }, + { kw: ['proliant', 'ilo', 'hewlett'], icon: siHp }, + + // SBC / DIY + { kw: ['raspberry', 'raspberrypi', 'rpi', 'raspi'], icon: siRaspberrypi }, + { kw: ['arduino'], icon: siArduino }, + + // Environnements de bureau + { kw: ['kde', 'plasma', 'kde desktop'], icon: siKde }, + + // Outils + { kw: ['excalidraw'], icon: siExcalidraw }, + + // Productivité / self-hosted + { kw: ['nextcloud'], icon: siNextcloud }, + { kw: ['paperless', 'paperless-ng', 'paperless-ngx'], icon: siPaperlessngx }, + { kw: ['uptime-kuma', 'uptimekuma', 'uptime kuma'], icon: siUptimekuma }, + { kw: ['mkdocs', 'material for mkdocs'], icon: siMaterialformkdocs }, + + // Médias / domotique + { kw: ['jellyfin'], icon: siJellyfin }, + { kw: ['homeassistant', 'home assistant', 'hassio', 'hass'], icon: siHomeassistant }, + { kw: ['philips hue', 'hue bridge', 'hue hub'], icon: siPhilipshue }, + { kw: ['xiaomi', 'mi home', 'yeelight'], icon: siXiaomi }, +] + +export function detectBrands(name, description) { + const text = ((name || '') + ' ' + (description || '')).toLowerCase() + if (!text.trim()) return [] + return BRANDS.filter(b => b.kw.some(kw => text.includes(kw))).map(b => b.icon) +} diff --git a/frontend/src/components/AccountModal.vue b/frontend/src/components/AccountModal.vue new file mode 100644 index 0000000..0486261 --- /dev/null +++ b/frontend/src/components/AccountModal.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/frontend/src/components/DeviceIcon.vue b/frontend/src/components/DeviceIcon.vue new file mode 100644 index 0000000..562f391 --- /dev/null +++ b/frontend/src/components/DeviceIcon.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/frontend/src/components/DeviceManager.vue b/frontend/src/components/DeviceManager.vue new file mode 100644 index 0000000..aa099f8 --- /dev/null +++ b/frontend/src/components/DeviceManager.vue @@ -0,0 +1,689 @@ + + + + + diff --git a/frontend/src/components/DiscoveryModal.vue b/frontend/src/components/DiscoveryModal.vue new file mode 100644 index 0000000..1d85b52 --- /dev/null +++ b/frontend/src/components/DiscoveryModal.vue @@ -0,0 +1,437 @@ + + + + + diff --git a/frontend/src/components/LoginPage.vue b/frontend/src/components/LoginPage.vue new file mode 100644 index 0000000..2640282 --- /dev/null +++ b/frontend/src/components/LoginPage.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/frontend/src/components/TopologyGraph.vue b/frontend/src/components/TopologyGraph.vue new file mode 100644 index 0000000..21b5ed7 --- /dev/null +++ b/frontend/src/components/TopologyGraph.vue @@ -0,0 +1,518 @@ + + + + + diff --git a/frontend/src/components/VlanManager.vue b/frontend/src/components/VlanManager.vue new file mode 100644 index 0000000..ff2e6f1 --- /dev/null +++ b/frontend/src/components/VlanManager.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js new file mode 100644 index 0000000..a965c13 --- /dev/null +++ b/frontend/src/i18n.js @@ -0,0 +1,477 @@ +import { ref } from 'vue' + +export const locale = ref(localStorage.getItem('locale') || 'fr') + +export function setLocale(lang) { + locale.value = lang + localStorage.setItem('locale', lang) +} + +const LANGS = { + fr: { + // Sidebar / App + tabTopology: 'Topologie', + tabNetworks: 'Réseaux', + tabDevices: 'Équipements', + discovery: '🔍 Découverte auto', + statsNetworks: 'Réseaux', + statsDevices: 'Équip.', + exportJson: '⬇ Export JSON', + importJson: '⬆ Import JSON', + loadError: 'Erreur de chargement : ', + importFailed: 'Import échoué : ', + importTooLarge: 'Fichier trop volumineux (max 5 Mo).', + // TopologyGraph + ping: 'Ping', + pinging: 'Ping en cours…', + refreshPing: 'Rafraîchir le ping', + wan: 'Internet / WAN', + gateway: 'Passerelle inter-VLAN', + unassigned: 'Non assigné', + noDevice: 'Aucun équipement', + noDevices: 'Aucun équipement à afficher.', + noDevicesHint: 'Commencez par créer des réseaux et des équipements.', + reachable: 'Joignable', + unreachable: 'Injoignable', + openWebUI: "Ouvrir l'interface web", + // DeviceManager + devices: 'Équipements', + addDevice: '+ Ajouter un équipement', + noDevicesConfigured: 'Aucun équipement configuré. Commencez par en ajouter un.', + searchPlaceholder: 'Nom, IP…', + filterType: 'Type', + filterNetwork: 'Réseau', + filterBrand: 'Marque', + filterVirt: 'Virt', + clearFilters: '✕ Effacer', + noDevicesFiltered: 'Aucun équipement ne correspond aux filtres sélectionnés.', + editDevice: "Modifier l'équipement", + newDevice: 'Nouvel équipement', + fieldName: 'Nom *', + fieldType: 'Type *', + fieldDescription: 'Description', + isGateway: 'Passerelle inter-VLAN', + isLivebox: 'Livebox / Box FAI', + accessUrl: "URL d'accès", + runtimeType: "Type d'environnement d'exécution", + notSpecified: '— Non précisé', + baremetal: 'Bare-metal', + lxcContainer: 'Conteneur LXC', + vmQemu: 'VM QEMU/KVM', + networkInterfaces: 'Interfaces réseau', + addInterface: '+ Interface', + noInterface: 'Aucune interface configurée', + cancel: 'Annuler', + save: 'Enregistrer', + create: 'Créer', + badgeGateway: 'Passerelle', + badgeLivebox: 'Livebox', + confirmDeleteDevice: '{0} et tous ses liens ?', + confirmDeleteNetwork: '{0} ?', + saveError: 'Erreur lors de la sauvegarde', + deleteError: 'Erreur lors de la suppression', + descPlaceholder: 'Rôle, OS, notes…', + // Device types + typeServer: 'Serveur', + typeSwitch: 'Switch', + typeRouter: 'Routeur', + typeNas: 'NAS', + typeGateway: 'Passerelle', + typeLivebox: 'Livebox', + typeCamera: 'Caméra IP', + typeTemperature: 'Sonde température/humidité', + typeSensor: 'Capteur (mouvement, ouverture…)', + typeHub: 'Hub domotique', + typeSmartPlug: 'Prise connectée', + typeAlarm: 'Alarme / Détecteur', + typeLight: 'Éclairage connecté', + typeDoorbell: 'Sonnette / Interphone', + typeAccessPoint: 'Borne WiFi / Access Point', + typeDesktop: 'Ordinateur fixe', + typeLaptop: 'Ordinateur portable', + typeOther: 'Autre', + virtBaremetal: 'Bare-metal', + virtLxc: 'LXC', + virtQemu: 'VM QEMU', + // VlanManager + networks: 'Réseaux', + addNetwork: '+ Ajouter un réseau', + noNetworksConfigured: 'Aucun réseau configuré. Commencez par en créer un.', + colType: 'Type', + colName: 'Nom', + colSubnet: 'Sous-réseau', + colColor: 'Couleur', + colActions: 'Actions', + editNetwork: 'Modifier le réseau', + newNetwork: 'Nouveau réseau', + vlanId: 'ID VLAN', + vlanIdHint: '(laisser vide pour LAN classique)', + subnet: 'Sous-réseau CIDR', + color: 'Couleur', + subnetPlaceholder: 'ex: 192.168.10.0/24', + // DiscoveryModal + autoDiscovery: 'Découverte automatique', + dnsServer: 'Serveur DNS', + dnsHint: 'Le reverse DNS sera interrogé sur ce serveur pour résoudre les noms.', + vlansToScan: 'VLANs à scanner', + vlansHint: 'Seuls les VLANs avec un sous-réseau CIDR configuré peuvent être scannés.', + noCidrWarning: "Aucun VLAN n'a de CIDR configuré. Renseignez-les dans l'onglet VLANs.", + noCidr: 'pas de CIDR', + startDiscovery: 'Lancer la découverte', + scanning: 'Scan en cours…', + scanAddresses: 'adresses sur', + scanVlans: 'VLAN(s)', + scanNote: 'Chaque hôte est pingé puis interrogé en DNS.', + hostsFound: 'hôte(s) découvert(s)', + addressesScanned: 'adresses scannées', + newHosts: 'nouveaux', + noHosts: 'Aucun hôte actif trouvé sur les plages sélectionnées.', + colIp: 'IP', + colDns: 'Nom (DNS)', + colStatus: 'Statut', + statusExisting: 'Déjà présent', + statusNew: 'Nouveau', + newScan: 'Nouveau scan', + importingBtn: 'Import…', + importBtn: 'Importer', + dnsRequired: 'Veuillez renseigner un serveur DNS.', + selectVlan: 'Sélectionnez au moins un VLAN.', + importError: "Erreur lors de l'import.", + scanError: 'Erreur lors du scan.', + // Theme / Lang + lightTheme: 'Thème clair', + darkTheme: 'Thème sombre', + // Auth + loginTitle: 'Connexion', + loginUsername: 'Nom d\'utilisateur', + loginPassword: 'Mot de passe', + loginBtn: 'Se connecter', + loginError: 'Identifiants incorrects.', + logout: 'Déconnexion', + accountSettings: 'Paramètres du compte', + currentPassword: 'Mot de passe actuel', + newUsername: 'Nouveau nom d\'utilisateur', + newPassword: 'Nouveau mot de passe', + confirmPassword: 'Confirmer le mot de passe', + leaveBlankToKeep: 'Laisser vide pour ne pas modifier', + passwordMismatch: 'Les mots de passe ne correspondent pas.', + accountUpdated: 'Compte mis à jour.', + wrongPassword: 'Mot de passe actuel incorrect.', + mustChangePasswordWarning: 'Pour des raisons de sécurité, vous devez changer votre mot de passe avant de continuer.', + newPasswordRequired: 'Un nouveau mot de passe est requis.', + passwordTooShort: 'Le mot de passe doit contenir au moins 8 caractères.', + passwordTooWeak: 'Le mot de passe doit contenir au moins une lettre et un chiffre.', + usernameInvalid: "Le nom d'utilisateur ne peut contenir que des lettres, chiffres, . _ - (1 à 64 caractères).", + tooManyAttempts: 'Trop de tentatives, réessayez plus tard.', + }, + + en: { + tabTopology: 'Topology', + tabNetworks: 'Networks', + tabDevices: 'Devices', + discovery: '🔍 Auto discovery', + statsNetworks: 'Networks', + statsDevices: 'Devices', + exportJson: '⬇ Export JSON', + importJson: '⬆ Import JSON', + loadError: 'Loading error: ', + importFailed: 'Import failed: ', + importTooLarge: 'File too large (max 5 MB).', + ping: 'Ping', + pinging: 'Pinging…', + refreshPing: 'Refresh ping', + wan: 'Internet / WAN', + gateway: 'Inter-VLAN Gateway', + unassigned: 'Unassigned', + noDevice: 'No devices', + noDevices: 'No devices to display.', + noDevicesHint: 'Start by creating networks and devices.', + reachable: 'Reachable', + unreachable: 'Unreachable', + openWebUI: 'Open web interface', + devices: 'Devices', + addDevice: '+ Add device', + noDevicesConfigured: 'No devices configured. Start by adding one.', + searchPlaceholder: 'Name, IP…', + filterType: 'Type', + filterNetwork: 'Network', + filterBrand: 'Brand', + filterVirt: 'Virt', + clearFilters: '✕ Clear', + noDevicesFiltered: 'No devices match the selected filters.', + editDevice: 'Edit device', + newDevice: 'New device', + fieldName: 'Name *', + fieldType: 'Type *', + fieldDescription: 'Description', + isGateway: 'Inter-VLAN gateway', + isLivebox: 'ISP Box / Router', + accessUrl: 'Access URL', + runtimeType: 'Runtime environment', + notSpecified: '— Not specified', + baremetal: 'Bare-metal', + lxcContainer: 'LXC container', + vmQemu: 'VM QEMU/KVM', + networkInterfaces: 'Network interfaces', + addInterface: '+ Interface', + noInterface: 'No interface configured', + cancel: 'Cancel', + save: 'Save', + create: 'Create', + badgeGateway: 'Gateway', + badgeLivebox: 'ISP Box', + confirmDeleteDevice: '{0} and all its links?', + confirmDeleteNetwork: '{0}?', + saveError: 'Error while saving', + deleteError: 'Error while deleting', + descPlaceholder: 'Role, OS, notes…', + typeServer: 'Server', + typeSwitch: 'Switch', + typeRouter: 'Router', + typeNas: 'NAS', + typeGateway: 'Gateway', + typeLivebox: 'ISP Box', + typeCamera: 'IP Camera', + typeTemperature: 'Temperature/humidity sensor', + typeSensor: 'Sensor (motion, door…)', + typeHub: 'Home automation hub', + typeSmartPlug: 'Smart plug', + typeAlarm: 'Alarm / Detector', + typeLight: 'Smart light', + typeDoorbell: 'Doorbell / Intercom', + typeAccessPoint: 'WiFi Access Point', + typeDesktop: 'Desktop computer', + typeLaptop: 'Laptop', + typeOther: 'Other', + virtBaremetal: 'Bare-metal', + virtLxc: 'LXC', + virtQemu: 'VM QEMU', + networks: 'Networks', + addNetwork: '+ Add network', + noNetworksConfigured: 'No networks configured. Start by creating one.', + colType: 'Type', + colName: 'Name', + colSubnet: 'Subnet', + colColor: 'Color', + colActions: 'Actions', + editNetwork: 'Edit network', + newNetwork: 'New network', + vlanId: 'VLAN ID', + vlanIdHint: '(leave empty for plain LAN)', + subnet: 'CIDR subnet', + color: 'Color', + subnetPlaceholder: 'e.g. 192.168.10.0/24', + autoDiscovery: 'Auto discovery', + dnsServer: 'DNS server', + dnsHint: 'Reverse DNS will be queried on this server to resolve hostnames.', + vlansToScan: 'VLANs to scan', + vlansHint: 'Only VLANs with a configured CIDR subnet can be scanned.', + noCidrWarning: 'No VLAN has a CIDR configured. Set them in the Networks tab.', + noCidr: 'no CIDR', + startDiscovery: 'Start discovery', + scanning: 'Scanning…', + scanAddresses: 'addresses on', + scanVlans: 'VLAN(s)', + scanNote: 'Each host is pinged then queried via DNS.', + hostsFound: 'host(s) found', + addressesScanned: 'addresses scanned', + newHosts: 'new', + noHosts: 'No active hosts found on the selected ranges.', + colIp: 'IP', + colDns: 'Name (DNS)', + colStatus: 'Status', + statusExisting: 'Already present', + statusNew: 'New', + newScan: 'New scan', + importingBtn: 'Importing…', + importBtn: 'Import', + dnsRequired: 'Please enter a DNS server.', + selectVlan: 'Select at least one VLAN.', + importError: 'Error during import.', + scanError: 'Error during scan.', + lightTheme: 'Light theme', + darkTheme: 'Dark theme', + // Auth + loginTitle: 'Sign in', + loginUsername: 'Username', + loginPassword: 'Password', + loginBtn: 'Sign in', + loginError: 'Incorrect credentials.', + logout: 'Logout', + accountSettings: 'Account settings', + currentPassword: 'Current password', + newUsername: 'New username', + newPassword: 'New password', + confirmPassword: 'Confirm password', + leaveBlankToKeep: 'Leave blank to keep unchanged', + passwordMismatch: 'Passwords do not match.', + accountUpdated: 'Account updated.', + wrongPassword: 'Current password is incorrect.', + mustChangePasswordWarning: 'For security reasons, you must change your password before continuing.', + newPasswordRequired: 'A new password is required.', + passwordTooShort: 'Password must be at least 8 characters.', + passwordTooWeak: 'Password must contain at least one letter and one digit.', + usernameInvalid: 'Username may only contain letters, digits, . _ - (1 to 64 characters).', + tooManyAttempts: 'Too many attempts, please try again later.', + }, + + es: { + tabTopology: 'Topología', + tabNetworks: 'Redes', + tabDevices: 'Equipos', + discovery: '🔍 Descubrimiento auto', + statsNetworks: 'Redes', + statsDevices: 'Equipos', + exportJson: '⬇ Exportar JSON', + importJson: '⬆ Importar JSON', + loadError: 'Error de carga: ', + importFailed: 'Importación fallida: ', + importTooLarge: 'Archivo demasiado grande (máx 5 MB).', + ping: 'Ping', + pinging: 'Ping en curso…', + refreshPing: 'Actualizar ping', + wan: 'Internet / WAN', + gateway: 'Pasarela inter-VLAN', + unassigned: 'Sin asignar', + noDevice: 'Sin equipos', + noDevices: 'No hay equipos que mostrar.', + noDevicesHint: 'Empiece creando redes y equipos.', + reachable: 'Alcanzable', + unreachable: 'No alcanzable', + openWebUI: 'Abrir interfaz web', + devices: 'Equipos', + addDevice: '+ Añadir equipo', + noDevicesConfigured: 'No hay equipos configurados. Empiece añadiendo uno.', + searchPlaceholder: 'Nombre, IP…', + filterType: 'Tipo', + filterNetwork: 'Red', + filterBrand: 'Marca', + filterVirt: 'Virt', + clearFilters: '✕ Borrar', + noDevicesFiltered: 'Ningún equipo coincide con los filtros seleccionados.', + editDevice: 'Editar equipo', + newDevice: 'Nuevo equipo', + fieldName: 'Nombre *', + fieldType: 'Tipo *', + fieldDescription: 'Descripción', + isGateway: 'Pasarela inter-VLAN', + isLivebox: 'Router / Box ISP', + accessUrl: 'URL de acceso', + runtimeType: 'Entorno de ejecución', + notSpecified: '— No especificado', + baremetal: 'Bare-metal', + lxcContainer: 'Contenedor LXC', + vmQemu: 'VM QEMU/KVM', + networkInterfaces: 'Interfaces de red', + addInterface: '+ Interfaz', + noInterface: 'Sin interfaces configuradas', + cancel: 'Cancelar', + save: 'Guardar', + create: 'Crear', + badgeGateway: 'Pasarela', + badgeLivebox: 'Router ISP', + confirmDeleteDevice: '{0} y todos sus enlaces?', + confirmDeleteNetwork: '{0}?', + saveError: 'Error al guardar', + deleteError: 'Error al eliminar', + descPlaceholder: 'Rol, SO, notas…', + typeServer: 'Servidor', + typeSwitch: 'Switch', + typeRouter: 'Router', + typeNas: 'NAS', + typeGateway: 'Pasarela', + typeLivebox: 'Router ISP', + typeCamera: 'Cámara IP', + typeTemperature: 'Sonda de temperatura/humedad', + typeSensor: 'Sensor (movimiento, apertura…)', + typeHub: 'Hub domótico', + typeSmartPlug: 'Enchufe inteligente', + typeAlarm: 'Alarma / Detector', + typeLight: 'Iluminación inteligente', + typeDoorbell: 'Timbre / Portero', + typeAccessPoint: 'Punto de acceso WiFi', + typeDesktop: 'Ordenador de sobremesa', + typeLaptop: 'Portátil', + typeOther: 'Otro', + virtBaremetal: 'Bare-metal', + virtLxc: 'LXC', + virtQemu: 'VM QEMU', + networks: 'Redes', + addNetwork: '+ Añadir red', + noNetworksConfigured: 'No hay redes configuradas. Empiece creando una.', + colType: 'Tipo', + colName: 'Nombre', + colSubnet: 'Subred', + colColor: 'Color', + colActions: 'Acciones', + editNetwork: 'Editar red', + newNetwork: 'Nueva red', + vlanId: 'ID de VLAN', + vlanIdHint: '(dejar vacío para LAN simple)', + subnet: 'Subred CIDR', + color: 'Color', + subnetPlaceholder: 'ej: 192.168.10.0/24', + autoDiscovery: 'Descubrimiento automático', + dnsServer: 'Servidor DNS', + dnsHint: 'El DNS inverso será consultado en este servidor para resolver nombres.', + vlansToScan: 'VLANs a escanear', + vlansHint: 'Solo los VLANs con subred CIDR configurada pueden escanearse.', + noCidrWarning: 'Ninguna VLAN tiene CIDR configurado. Configúrelo en la pestaña Redes.', + noCidr: 'sin CIDR', + startDiscovery: 'Iniciar descubrimiento', + scanning: 'Escaneo en curso…', + scanAddresses: 'direcciones en', + scanVlans: 'VLAN(s)', + scanNote: 'Cada host es pingado y luego consultado en DNS.', + hostsFound: 'host(s) descubierto(s)', + addressesScanned: 'direcciones escaneadas', + newHosts: 'nuevos', + noHosts: 'No se encontraron hosts activos en los rangos seleccionados.', + colIp: 'IP', + colDns: 'Nombre (DNS)', + colStatus: 'Estado', + statusExisting: 'Ya presente', + statusNew: 'Nuevo', + newScan: 'Nuevo escaneo', + importingBtn: 'Importando…', + importBtn: 'Importar', + dnsRequired: 'Por favor ingrese un servidor DNS.', + selectVlan: 'Seleccione al menos una VLAN.', + importError: 'Error durante la importación.', + scanError: 'Error durante el escaneo.', + lightTheme: 'Tema claro', + darkTheme: 'Tema oscuro', + // Auth + loginTitle: 'Iniciar sesión', + loginUsername: 'Nombre de usuario', + loginPassword: 'Contraseña', + loginBtn: 'Iniciar sesión', + loginError: 'Credenciales incorrectas.', + logout: 'Cerrar sesión', + accountSettings: 'Configuración de cuenta', + currentPassword: 'Contraseña actual', + newUsername: 'Nuevo nombre de usuario', + newPassword: 'Nueva contraseña', + confirmPassword: 'Confirmar contraseña', + leaveBlankToKeep: 'Dejar en blanco para no cambiar', + passwordMismatch: 'Las contraseñas no coinciden.', + accountUpdated: 'Cuenta actualizada.', + wrongPassword: 'La contraseña actual es incorrecta.', + mustChangePasswordWarning: 'Por razones de seguridad, debe cambiar su contraseña antes de continuar.', + newPasswordRequired: 'Se requiere una nueva contraseña.', + passwordTooShort: 'La contraseña debe tener al menos 8 caracteres.', + passwordTooWeak: 'La contraseña debe contener al menos una letra y un número.', + usernameInvalid: 'El nombre de usuario solo puede contener letras, dígitos, . _ - (1 a 64 caracteres).', + tooManyAttempts: 'Demasiados intentos, inténtelo de nuevo más tarde.', + }, +} + +export function t(key) { + return LANGS[locale.value]?.[key] ?? LANGS['fr'][key] ?? key +} + +export function tFmt(key, ...args) { + let str = LANGS[locale.value]?.[key] ?? LANGS['fr'][key] ?? key + args.forEach((arg, i) => { str = str.replace(`{${i}}`, arg) }) + return str +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..1d4acd7 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,3 @@ +import { createApp } from 'vue' +import App from './App.vue' +createApp(App).mount('#app') diff --git a/frontend/src/theme.js b/frontend/src/theme.js new file mode 100644 index 0000000..2932886 --- /dev/null +++ b/frontend/src/theme.js @@ -0,0 +1,18 @@ +import { ref, watch } from 'vue' + +export const theme = ref(localStorage.getItem('theme') || 'light') + +function apply(t) { + document.documentElement.classList.toggle('dark', t === 'dark') +} + +apply(theme.value) + +watch(theme, (t) => { + localStorage.setItem('theme', t) + apply(t) +}) + +export function toggleTheme() { + theme.value = theme.value === 'dark' ? 'light' : 'dark' +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..e8217ad --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + build: { + modulePreload: { polyfill: false }, + }, + server: { + host: true, + proxy: { + '/api': { + target: 'http://backend:8000', + changeOrigin: true, + } + } + } +})