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
+103
View File
@@ -0,0 +1,103 @@
# Architecture
## Overview
Stupid Simple Network Inventory is a self-hosted web application for manual network inventory and logical topology visualisation. There is no auto-discovery of topology — only ICMP reachability scanning. All data is entered manually.
## Request Flow
```
Browser
│ HTTP :8080
┌─────────────────────────────────────┐
│ Nginx (frontend container) │
│ serves /usr/share/nginx/html │
│ │
│ location /api/ → proxy_pass │
│ http://backend:8000/api/ │
└──────────────────┬──────────────────┘
│ HTTP :8000 (Docker internal network)
┌─────────────────────────────────────┐
│ FastAPI (backend container) │
│ uvicorn 0.0.0.0:8000 │
│ │
│ /app/data/ (bind mount) │
│ topology.db ← SQLite │
│ secret_key.txt ← JWT secret │
└─────────────────────────────────────┘
```
## Docker Compose
Two services defined in `docker-compose.yml`:
| Resource | Type | Notes |
|----------|------|-------|
| `backend` | service | Python 3.11-slim, runs as `DOCKER_UID:DOCKER_GID` (host user), `cap_drop: ALL`, `cap_add: NET_RAW` |
| `frontend` | service | Multi-stage (Vite → `nginxinc/nginx-unprivileged`), UID 101, `cap_drop: ALL`, `no-new-privileges`, exposes `:8080` |
`./db_data:/app/data` is a bind mount. The backend process runs with the same UID/GID as the host user owning `./db_data/` (set via `DOCKER_UID`/`DOCKER_GID` in `.env`). Pre-create the directory before the first run: `mkdir -p db_data`.
Both containers share an internal Docker bridge network (`internal`). The browser never communicates directly with the backend — all API traffic goes through Nginx.
## Build Pipeline
**Backend** (`backend/Dockerfile`):
1. `python:3.11-slim` base
2. Install `iputils-ping` (required for ICMP subprocess calls)
3. `pip install -r requirements.txt`
4. Start with `uvicorn main:app --host 0.0.0.0 --port 8000`
**Frontend** (`frontend/Dockerfile`):
1. Stage 1: `node:20-alpine` — runs `vite build`, outputs to `/app/dist`
2. Stage 2: `nginx:alpine` — copies `/app/dist` to `/usr/share/nginx/html`, copies `nginx.conf`
## Startup Sequence (backend)
`main.py` runs synchronously at import time before the ASGI app is created:
1. `_migrate_vlan_nullable()` — idempotent DDL fix for vlans table
2. `_migrate_device_virt_type()` — adds column if missing
3. `_migrate_device_url()` — adds column if missing
4. `_migrate_users()` — creates users table + seeds admin account
5. `Base.metadata.create_all(bind=engine)` — creates any missing tables
6. FastAPI app instance created, routers registered
This ordering ensures migrations never run against an uninitialised schema.
## Authentication Architecture
```
LoginPage.vue
│ POST /api/auth/login (form-urlencoded)
│ ← { access_token, token_type, username, must_change_password }
auth.js (setAuth)
stores token + username + mustChangePassword in localStorage
─────────────────────────────────────────────────────────────
isAuthenticated (computed ref) ← App.vue reads this
mustChangePassword (computed ref) ← App.vue shows AccountModal :forced when true
getToken() ← api.js reads this per request
App.vue template guard:
!isAuthenticated → <LoginPage>
mustChangePassword → <AccountModal :forced="true"> (blocks the app)
else → full application
api.js (axios interceptor)
every request → Authorization: Bearer <token>
every 401 response (if token existed) → clearAuth() + reload
```
The JWT payload is `{ sub: username, ver: token_version, exp: <24h> }`, signed with HS256.
The secret is loaded from `data/secret_key.txt` (auto-generated on first run, permissions 0600) or `SECRET_KEY` env var.
Password change invalidates previous tokens immediately via `token_version` bump.
## Persistence
All state is in SQLite at `./db_data/topology.db`. There is no caching layer, no background jobs, no message queue. The only writable files at runtime are `topology.db` and `secret_key.txt`, both in the bind-mounted `./db_data/` directory on the host.
+273
View File
@@ -0,0 +1,273 @@
# Backend Reference
## Entry Point — `main.py`
FastAPI application. Four router groups protected by `require_password_changed` (which implies `get_current_user` + rejects users with `must_change_password=True`).
```python
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(vlans.router, prefix="/api/vlans", dependencies=[Depends(require_password_changed)])
app.include_router(devices.router, prefix="/api/devices", dependencies=[Depends(require_password_changed)])
app.include_router(discovery.router, prefix="/api/discovery", dependencies=[Depends(require_password_changed)])
```
`GET /api/health` returns `{"status": "ok"}` — public, intentionally minimal (no sensitive detail).
**CORS**: controlled by the `ALLOWED_ORIGINS` environment variable (default `"*"`). Set to your domain in production or leave empty to disable CORS headers (same-origin via proxy).
---
## API Endpoints
### Auth — `/api/auth`
#### `POST /api/auth/login`
- **Auth**: None
- **Content-Type**: `application/x-www-form-urlencoded`
- **Body**: `username=<str>&password=<str>`
- **Response**: `{ access_token: str, token_type: "bearer", username: str, must_change_password: bool }`
- **Errors**: 401 if credentials invalid, 429 if rate-limited (IP or username)
#### `PUT /api/auth/account`
- **Auth**: Bearer token (works even when `must_change_password=True`)
- **Body** (JSON): `{ current_password: str, new_username?: str, new_password?: str }`
- **Response**: `{ access_token: str, token_type: "bearer", username: str, must_change_password: bool }` (new token)
- **Errors**: 400 `"Current password is incorrect"`, 400 `"Username already taken"`, 400 `"password_too_short"`, 400 `"password_too_weak"`, 400 `"username_invalid"`
- **Note**: changing the password bumps `token_version`, invalidating all previously issued tokens.
#### `GET /api/auth/me`
- **Auth**: Bearer token
- **Response**: `{ username: str, must_change_password: bool }`
---
### VLANs — `/api/vlans`
All endpoints require auth.
#### `GET /api/vlans/`
Returns all networks sorted: LANs first (NULL `vlan_id`), then VLANs ascending by `vlan_id`.
```json
[{ "id": 1, "vlan_id": null, "name": "LAN", "cidr": "192.168.1.0/24", "color": "#4A90D9" }]
```
#### `POST /api/vlans/`
Creates a network. `vlan_id` is optional — omit for a plain LAN.
#### `PUT /api/vlans/{id}`
Full replacement of a network record.
#### `DELETE /api/vlans/{id}`
Deletes the network. SQLAlchemy cascades: associated `DeviceInterface` rows will lose their `vlan_id` FK (set to NULL, since the column is nullable).
---
### Devices — `/api/devices`
All endpoints require auth.
#### `GET /api/devices/`
Returns all devices with their interfaces, ordered by name.
```json
[{
"id": 1,
"name": "proxmox-01",
"type": "server",
"description": "Proxmox hypervisor",
"is_gateway": false,
"is_livebox": false,
"virt_type": "baremetal",
"url": "https://proxmox.local:8006",
"interfaces": [
{ "id": 1, "device_id": 1, "vlan_id": 2, "ip_address": "192.168.10.5", "name": "eth0", "is_upstream": false }
]
}]
```
#### `POST /api/devices/`
Creates device with its interfaces in a single transaction (`flush` + batch insert + `commit`).
#### `PUT /api/devices/{id}`
Replaces the device and its interfaces: existing interfaces are deleted, new ones inserted.
#### `DELETE /api/devices/{id}`
Deletes device and its interfaces (cascade via SQLAlchemy `delete-orphan`).
**Important**: when adding a new field to `Device`, update all four of: `models.py`, `_migrate_*` in `main.py`, `DeviceCreate`/`DeviceOut` in `routers/devices.py`, and the explicit assignments in `create_device` / `update_device`. Do not rely on `**model_dump()` to populate the ORM object — it will silently drop new fields.
---
### Discovery — `/api/discovery`
All endpoints require auth.
#### `POST /api/discovery/ping`
Parallel ICMP ping of a list of IPs, 50 concurrent workers.
```json
// Request
{ "ips": ["192.168.1.1", "192.168.1.2"] }
// Response
[{ "ip": "192.168.1.1", "alive": true }, { "ip": "192.168.1.2", "alive": false }]
```
Uses `subprocess.run(["ping", "-c", "1", "-W", "1", ip])`. Requires `cap_add: NET_RAW` on the container (already configured in `docker-compose.yml`).
#### `POST /api/discovery/scan`
Ping sweep + DNS PTR reverse lookup for one or more CIDR ranges.
```json
// Request
{
"dns_server": "192.168.1.1",
"targets": [
{ "vlan_id": 1, "cidr": "192.168.1.0/24" }
]
}
// Response
{
"hosts": [{ "ip": "192.168.1.5", "hostname": "nas.local", "vlan_id": 1, "cidr": "192.168.1.0/24" }],
"total_scanned": 254,
"duration_s": 3.2
}
```
Maximum 1024 hosts per target (rejects /21 and wider). 100 concurrent workers. DNS queries use `dnspython` with a 1s timeout.
---
## Database — `database.py`
```python
engine = create_engine("sqlite:///./data/topology.db", connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db(): # FastAPI dependency
db = SessionLocal()
try:
yield db
finally:
db.close()
```
`check_same_thread=False` is required for SQLite under a multi-threaded ASGI server. The `data/` directory is created at module import time.
---
## Models — `models.py`
### User
```python
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String, unique=True, nullable=False)
hashed_password = Column(String, nullable=False)
must_change_password = Column(Boolean, nullable=False, default=False, server_default="0")
token_version = Column(Integer, nullable=False, default=1, server_default="1")
```
`must_change_password`: set to `True` when admin is bootstrapped with the default password. Clears to `False` on successful `PUT /api/auth/account` with a new password.
`token_version`: incremented on every password change. Included in JWT payload as `ver`. A token is rejected if its `ver` differs from the current `token_version`.
### Vlan
```python
class Vlan(Base):
__tablename__ = "vlans"
id = Column(Integer, primary_key=True)
vlan_id = Column(Integer, unique=True, nullable=True) # NULL = plain LAN
name = Column(String, nullable=False)
cidr = Column(String, nullable=True, default="")
color = Column(String, default="#4A90D9")
interfaces = relationship("DeviceInterface", back_populates="vlan")
```
### Device
```python
class Device(Base):
__tablename__ = "devices"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
type = Column(String, default="other")
description = Column(String, default="")
is_gateway = Column(Boolean, default=False)
is_livebox = Column(Boolean, default=False)
virt_type = Column(String, nullable=True) # null | baremetal | lxc | qemu
url = Column(String, nullable=True)
interfaces = relationship("DeviceInterface", back_populates="device", cascade="all, delete-orphan")
```
### DeviceInterface
```python
class DeviceInterface(Base):
__tablename__ = "device_interfaces"
id = Column(Integer, primary_key=True)
device_id = Column(Integer, ForeignKey("devices.id"), nullable=False)
vlan_id = Column(Integer, ForeignKey("vlans.id"), nullable=True)
ip_address = Column(String, nullable=True, default="")
name = Column(String, default="eth0")
is_upstream = Column(Boolean, default=False)
```
---
## Auth — `routers/auth.py`
Key details for maintainers:
- **Secret key**: loaded from `data/secret_key.txt` (auto-generated with `secrets.token_hex(32)`, permissions 0600) or `SECRET_KEY` env var. Same Docker volume as the database — survives restarts. See README for rotation procedure.
- **Token**: HS256 JWT, payload `{ sub: username, ver: token_version, exp: utcnow + 24h }`.
- **Token invalidation**: changing the password increments `User.token_version`. `get_current_user` rejects any token whose `ver` differs from the DB value → immediate invalidation of all old sessions on password change.
- **Password hashing**: `passlib.CryptContext` with `bcrypt`. `requirements.txt` pins `bcrypt==3.2.2` because `passlib 1.7.4` is incompatible with `bcrypt >= 4.0`.
- **`get_current_user`**: validates JWT + token version. Returns the `User` ORM object, or raises 401.
- **`require_password_changed`**: wraps `get_current_user`, raises 403 if `must_change_password=True`. Used as the dependency for all business routers.
- **Rate limiting**: 20 attempts/IP/60s and 10 attempts/username/15min (in-memory). Complemented by Nginx `limit_req` (10 req/min per IP, burst 5) on the login endpoint.
---
## Migrations — `main.py`
All migration functions follow this idempotent pattern:
```python
def _migrate_example():
with engine.connect() as conn:
# 1. Bail out if table doesn't exist yet
if not conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='table_name'")).fetchone():
return
# 2. Check if work is needed
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(table_name)")).fetchall()]
if 'new_column' not in cols:
conn.execute(text("ALTER TABLE table_name ADD COLUMN new_column VARCHAR"))
conn.commit()
```
For table recreation (when SQLite cannot ALTER COLUMN), the pattern is:
1. Create `table_new` with the desired schema
2. `INSERT INTO table_new SELECT ... FROM table`
3. `DROP TABLE table`
4. `ALTER TABLE table_new RENAME TO table`
5. Wrap in `PRAGMA foreign_keys=OFF/ON`
Always call `conn.commit()` explicitly — SQLAlchemy 2.0 does not auto-commit DDL on SQLite.
The `_migrate_users()` function has a two-phase check: table existence, then `COUNT(*) = 0` for seeding. This handles the edge case where `create_all` created the table empty before the migration ran.
`_migrate_force_admin_password_change()` is the "catch-up" migration for existing installations: if the admin account uses the default bootstrap password ("admin") and `must_change_password=0`, it sets `must_change_password=1`. This runs at every startup and is idempotent.
**Startup call order** (as of phase 3):
1. `_migrate_vlan_nullable()`
2. `_migrate_device_virt_type()`
3. `_migrate_device_url()`
4. `_migrate_users_must_change_password()`
5. `_migrate_users_token_version()`
6. `_migrate_force_admin_password_change()`
7. `_migrate_drop_links_table()` — drops the `links` table if it exists (feature removed in phase 3)
8. `_migrate_users()`
9. `Base.metadata.create_all(bind=engine)`
+124
View File
@@ -0,0 +1,124 @@
# Data Model
## Schema Overview
```
users
id PK
username UNIQUE NOT NULL
hashed_password NOT NULL
vlans
id PK
vlan_id UNIQUE NULL ← NULL = plain LAN (no 802.1Q tag)
name NOT NULL
cidr NULL
color NULL
devices
id PK
name NOT NULL
type
description
is_gateway BOOL
is_livebox BOOL
virt_type NULL ← null | baremetal | lxc | qemu
url NULL
device_interfaces
id PK
device_id FK → devices.id NOT NULL
vlan_id FK → vlans.id NULL ← NULL = no network assignment
ip_address NULL
name
is_upstream BOOL
links
id PK
source_device_id FK → devices.id NOT NULL
target_device_id FK → devices.id NOT NULL
link_type
description
```
## Relationships
- `Device` 1 → N `DeviceInterface` (cascade delete-orphan)
- `Vlan` 1 → N `DeviceInterface` (nullable FK — deleting a VLAN sets `vlan_id` to NULL on its interfaces)
- `Link` references `Device` twice (source, target) — links are deleted explicitly in `delete_device` before removing the device
## Key Design Decisions
### `vlan_id` is nullable
A network with `vlan_id = NULL` is a plain LAN without 802.1Q tagging. This allows modelling both tagged and untagged networks in the same table. The UI distinguishes them with "LAN" vs "VLAN X" badges.
The `vlans` table was originally created with `vlan_id NOT NULL`. The migration `_migrate_vlan_nullable()` recreates it (SQLite cannot `ALTER COLUMN`).
### Interfaces as a separate table
A device can belong to multiple networks simultaneously. The `device_interfaces` table is a join table with extra attributes (IP, name, is_upstream). A device appears in every topology card that corresponds to one of its interfaces.
### `is_gateway` and `is_livebox`
These boolean flags drive the special WAN and Gateway cards in the topology view. They are independent of network membership — a gateway device can also have interfaces in various VLANs.
### No foreign key enforcement at the SQLite level
SQLAlchemy's `ForeignKey` declarations define the schema, but SQLite does not enforce FK constraints unless `PRAGMA foreign_keys=ON` is set per-connection. The application does not enable this pragma, so referential integrity is maintained by application logic (explicit deletes in the routers).
## SQLite Migrations
Migrations are idempotent Python functions that run at startup before `Base.metadata.create_all`. They check for the presence of columns or tables before acting, so they are safe to run on every container start.
Pattern for adding a nullable column:
```python
def _migrate_my_column():
with engine.connect() as conn:
if not conn.execute(text(
"SELECT name FROM sqlite_master WHERE type='table' AND name='devices'"
)).fetchone():
return
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(devices)")).fetchall()]
if 'my_column' not in cols:
conn.execute(text("ALTER TABLE devices ADD COLUMN my_column VARCHAR"))
conn.commit()
```
## Device Types
18 valid values for `devices.type`:
| Value | Description |
|-------|-------------|
| `server` | Physical or virtual server |
| `switch` | Network switch |
| `router` | Network router |
| `nas` | Network-attached storage |
| `gateway` | Internet gateway / firewall |
| `livebox` | ISP-provided box (WAN) |
| `access_point` | Wi-Fi access point |
| `camera` | IP camera |
| `temperature` | Temperature sensor |
| `sensor` | Generic sensor |
| `hub` | Network hub |
| `smart_plug` | Smart power plug |
| `alarm` | Alarm system |
| `light` | Smart light |
| `doorbell` | Smart doorbell |
| `desktop` | Desktop computer |
| `laptop` | Laptop computer |
| `other` | Anything else |
`url` is hidden in the device form for `desktop` and `laptop` — these types have no web UI.
## Virtualisation Types
`devices.virt_type` valid values:
| Value | Display | Badge |
|-------|---------|-------|
| `null` | (not shown) | — |
| `baremetal` | Bare metal | — |
| `lxc` | LXC container | blue "LXC" |
| `qemu` | QEMU/KVM VM | purple "VM" |
+185
View File
@@ -0,0 +1,185 @@
# Extending the Application
## Add a Field to `Device`
Every new Device field requires changes in four places. Missing any one of them will cause data loss or silent failures.
### 1. `backend/models.py`
Add the column to the `Device` class:
```python
my_field = Column(String, nullable=True)
```
### 2. `backend/main.py`
Add an idempotent migration function and call it before `Base.metadata.create_all`:
```python
def _migrate_device_my_field():
with engine.connect() as conn:
if not conn.execute(text(
"SELECT name FROM sqlite_master WHERE type='table' AND name='devices'"
)).fetchone():
return
cols = [row[1] for row in conn.execute(text("PRAGMA table_info(devices)")).fetchall()]
if 'my_field' not in cols:
conn.execute(text("ALTER TABLE devices ADD COLUMN my_field VARCHAR"))
conn.commit()
# Add the call at the bottom of the startup sequence:
_migrate_device_my_field()
```
### 3. `backend/routers/devices.py`
Add to both Pydantic models:
```python
class DeviceCreate(BaseModel):
my_field: Optional[str] = None
class DeviceOut(BaseModel):
my_field: Optional[str] = None
```
Then assign explicitly in both `create_device` and `update_device`:
```python
db_device = models.Device(
...
my_field=device.my_field,
)
```
Do **not** use `**device.model_dump()` to populate the ORM object — it silently ignores any field not present in the constructor keyword args.
### 4. Frontend
Add the field to the device form in `DeviceManager.vue` and display it where needed (chip in `TopologyGraph.vue`, card in `DeviceManager.vue`). Add any new i18n keys to all three locales in `i18n.js`.
---
## Add a New API Route Group
1. Create `backend/routers/myrouter.py` following the pattern of `vlans.py` or `devices.py`.
2. Import and register in `backend/main.py`:
```python
from routers import myrouter
app.include_router(myrouter.router, prefix="/api/myroutes", tags=["myroutes"],
dependencies=[Depends(get_current_user)])
```
3. Add the corresponding API object in `frontend/src/api.js`:
```javascript
export const myApi = {
list: () => http.get('/myroutes/'),
create: (data) => http.post('/myroutes/', data),
remove: (id) => http.delete(`/myroutes/${id}`),
}
```
---
## Add a New i18n Key
1. Open `frontend/src/i18n.js`.
2. Add the key to all three locale objects (`fr`, `en`, `es`).
3. Use `t('myKey')` in the template.
For interpolation:
```javascript
// i18n.js
fr: { myMsg: "Il y a {0} équipements dans {1}" }
// template
{{ tFmt('myMsg', devices.length, vlan.name) }}
```
---
## Add a Language
1. In `frontend/src/i18n.js`, add a new locale object with all existing keys translated.
2. Add the language code to the valid values type/comment.
3. In `App.vue`, add a pill button to the language switcher:
```html
<button :class="{ active: locale === 'de' }" @click="setLocale('de')">de</button>
```
---
## Add a Brand Icon
Supported brands are detected by keyword-matching device name and description.
1. Find the Simple Icons export name:
```bash
cd frontend
node -e "const si = require('./node_modules/simple-icons'); console.log(Object.keys(si).filter(k => k.toLowerCase().includes('yourterm')))"
```
2. In `frontend/src/brandIcons.js`, import the icon:
```javascript
import { siYourbrand } from 'simple-icons'
```
3. Add an entry to the `BRANDS` array:
```javascript
{
id: 'yourbrand',
name: 'YourBrand',
keywords: ['yourbrand', 'alternate-name'],
hex: siYourbrand.hex,
path: siYourbrand.path,
}
```
`keywords` are matched case-insensitively against the device `name` and `description` fields.
---
## Add a Vue Component
1. Create `frontend/src/components/MyComponent.vue` using `<script setup>` syntax.
2. Use CSS custom properties for all colors (see `docs/frontend.md` for the variable list).
3. Use `t('key')` for all visible strings — never hardcode text.
4. For dark-mode scoped overrides, put the full selector inside `:global()`:
```css
:global(html.dark .my-component) { background: #1E293B; }
```
5. Import and use in `App.vue` or the relevant parent component.
---
## Change the Default Admin Password
The admin account is seeded by `_migrate_users()` only when the `users` table is empty. To reset credentials on a running instance, use the account settings modal in the UI (sidebar footer → account icon).
To reset programmatically (e.g., locked out):
```bash
docker compose exec backend python3 -c "
from database import engine
from sqlalchemy import text
from passlib.context import CryptContext
pwd = CryptContext(schemes=['bcrypt'], deprecated='auto')
with engine.connect() as conn:
conn.execute(text(\"UPDATE users SET hashed_password=:h WHERE username='admin'\"), {'h': pwd.hash('newpassword')})
conn.commit()
"
```
---
## Upgrade Dependencies
### Backend Python packages
Check for passlib/bcrypt compatibility before upgrading:
- `passlib 1.7.4` requires `bcrypt < 4.0` — see `requirements.txt` for the pin.
- If upgrading passlib, verify the new version supports the installed bcrypt before removing the pin.
### Frontend npm packages
`simple-icons` has breaking changes between major versions (icon names and SVG paths change). Pin the major version in `package.json` and test brand detection after upgrading.
`lucide-vue-next` is generally backwards-compatible but icons are occasionally renamed or removed.
+262
View File
@@ -0,0 +1,262 @@
# Frontend Reference
## Stack
- **Vue 3** (Composition API, `<script setup>`)
- **Vite 5** (build + dev server)
- **Axios** (HTTP client)
- **lucide-vue-next** (generic UI icons)
- **simple-icons** (brand logos as inline SVG)
No Pinia, no Vue Router (single page, tab switching via reactive state in `App.vue`).
---
## Module Inventory
### `src/main.js`
Minimal entry point: creates the Vue app and mounts it to `#app`.
---
### `src/auth.js`
Reactive authentication state. All consuming components import from here — there is no Vuex/Pinia store.
```javascript
// Exports
isAuthenticated // computed ref<boolean> — true if token is present
currentUsername // computed ref<string | null>
setAuth(token, username) // stores in reactive refs + localStorage
clearAuth() // clears refs + localStorage
getToken() // returns raw token string (used by api.js interceptor)
```
State is initialised from `localStorage` at module load, so auth survives page refreshes.
---
### `src/api.js`
Thin axios wrapper. All requests go to `/api` (relative URL, proxied by Nginx).
**Request interceptor**: injects `Authorization: Bearer <token>` if `getToken()` is non-null.
**Response interceptor**: on 401, calls `clearAuth()` and reloads the page — but only if a token was present (avoids an infinite reload loop if the login endpoint itself returns 401).
```javascript
// Named exports
authApi // login, updateAccount, me
vlansApi // list, create, update, remove
devicesApi // list, create, update, remove
linksApi // list, create, remove
discoveryApi // scan, ping
```
`authApi.login` sends `application/x-www-form-urlencoded` (required by FastAPI's `OAuth2PasswordRequestForm`). All other calls use JSON.
---
### `src/i18n.js`
Simple key-based i18n, no external library.
```javascript
// Exports
locale // ref<'fr'|'en'|'es'> — persisted in localStorage, default 'fr'
setLocale(lang)
t(key) // returns translation, fallback to 'fr', then raw key
tFmt(key, ...args) // t() with {0}, {1}, ... interpolation
```
Translations are plain objects keyed by locale code. To add a key: add it to all three locale objects. To add a language: add a new locale object and add the code to the switcher pills in `App.vue`.
**Rule**: every visible string in a template must use `t('key')`. Never hardcode text.
---
### `src/theme.js`
```javascript
// Exports
theme // ref<'light'|'dark'> — persisted in localStorage, default 'light'
toggleTheme()
```
Applies/removes the `html.dark` class on `document.documentElement`. All dark-mode styling is driven by CSS custom properties defined in `App.vue`'s global `<style>`.
---
### `src/brandIcons.js`
```javascript
// Exports
BRANDS // array of { id, name, keywords[], hex, path }
detectBrands(name, description) // returns BRANDS entries matching the device
```
`detectBrands` is a pure function — it takes name and description strings and returns all matching brand objects. It is called in both `DeviceIcon.vue` and `DeviceManager.vue`.
To add a brand:
1. Check the `simple-icons` export name: `node -e "const si = require('./node_modules/simple-icons'); console.log(Object.keys(si).filter(k => k.toLowerCase().includes('yourterm')))"`
2. Import `si<Name>` from `simple-icons`
3. Add an entry to `BRANDS`: `{ id: 'yourbrand', name: 'YourBrand', keywords: ['keyword1', 'keyword2'], hex: si<Name>.hex, path: si<Name>.path }`
---
## Component Inventory
### `App.vue`
Global layout and state. Single-file, no sub-layouts.
**Auth guard**: `v-if="!isAuthenticated"` renders `<LoginPage>`, `v-else` renders the main layout. The template is wrapped in `<div class="app-root">` (`display: flex; flex-direction: column; min-height: 100vh`) — single root node to prevent a Firefox autofill overlay bug on Vue fragment comment nodes. Do **not** use `display: contents` here: Firefox fails to update click hit-testing after a child replacement inside a `display: contents` element, causing buttons to be unclickable after login.
**Global state** (reactive refs at the top of `<script setup>`):
- `vlans` — array loaded from `GET /api/vlans/`
- `devices` — array loaded from `GET /api/devices/`
- `activeTab``'topology' | 'devices' | 'vlans'`
**Auth functions**: `onLogin({ token, username })`, `onAccountUpdated({ token, username })`, `logout()` — all call `setAuth`/`clearAuth` from `auth.js`.
**CSS variables** are defined here in the global (non-scoped) `<style>` block:
- `:root` — light theme
- `html.dark` — dark theme overrides
---
### `LoginPage.vue`
Full-screen centered login card. No router navigation — it is shown/hidden by `App.vue`'s auth guard.
**Props**: none
**Emits**: `login` with payload `{ token: string, username: string }`
Calls `authApi.login(username, password)`. On success, emits the token. On failure, shows a localised error message.
---
### `AccountModal.vue`
Modal for changing username and/or password.
**Props**: none (reads `currentUsername` directly from `auth.js`)
**Emits**: `close`, `updated` with payload `{ token: string, username: string }`
Fields: new username (optional), new password (optional), confirm password (shown only when new password is typed), current password (always required).
Calls `authApi.updateAccount({ current_password, new_username?, new_password? })`. On success, emits the new token so `App.vue` can refresh auth state.
---
### `TopologyGraph.vue`
Topology view. CSS cards only — no SVG, no Cytoscape.
**Props**: `vlans[]`, `devices[]`
**Emits**: none (read-only view)
**Layout**:
```
toolbar (Ping button + up/down counter)
├── WAN card (is_livebox devices)
├── Gateway card (is_gateway devices)
├── [one card per network, sorted: LANs first then VLANs ascending]
└── Unassigned card (no interface, not livebox, not gateway)
```
Inside each card, devices are sorted by IP address ascending. Devices with no IP go last. A device appears in every card that matches one of its interfaces.
Each device is rendered as a **chip** — a compact tile with:
- Left: type icon (Lucide, always, `typeOnly` prop)
- Center: name + IP pill + interface name + brand SVG logos (11px)
- Right tags column: Link (green, `<a>`) | GW | LXC | VM | ping dot
**Ping**: `POST /api/discovery/ping` with all known IPs. Populates `pingResults` reactive map `{ ip → alive }`. A device is "up" if any of its IPs maps to `alive: true`.
**Dark mode CSS**: scoped styles use `:global(html.dark .special-wan)` etc. The full selector must be inside `:global()` — Vue's scoped CSS transformer does not resolve descendant selectors after the closing parenthesis.
---
### `DeviceIcon.vue`
**Props**: `device` (object with `name`, `description`, `type`), `typeOnly` (boolean, default false)
- `typeOnly: false` — shows brand SVG logo(s) if `detectBrands()` finds a match, else falls back to the Lucide type icon. Used in topology chips.
- `typeOnly: true` — always shows the Lucide type icon. Used in DeviceManager cards.
Lucide icon is selected by a `type → component` map covering all 18 device types.
---
### `DeviceManager.vue`
CRUD view for devices. Presented as a card list.
**Props**: `vlans[]`, `devices[]`
**Emits**: `refresh` (triggers parent reload of both vlans and devices)
**Filter bar** (28px height):
- Free-text search (name, description, IP) — cleared by Escape
- Type multi-select (only types present in data)
- Network dropdown (color swatch + name)
- Brand dropdown (SVG icon + name, alphabetical)
- Virt dropdown (only shown if at least one device has a `virt_type`)
Filters combine with AND across categories, OR within. Active filter shows `X / total` counter and a "Clear" button.
`deviceTypes` is a `computed` (not a constant) so labels re-render on locale change.
**Form**: inline panel (not a modal). Supports add and edit. Interfaces are managed as a sub-list within the same form.
---
### `VlanManager.vue`
CRUD view for networks (VLANs and plain LANs).
**Props**: `vlans[]`
**Emits**: `refresh`
`vlan_id` is optional. If omitted, the network is a plain LAN (badge "LAN"). If provided, it is a VLAN (badge "VLAN X"). Color picker included. CIDR is a free-text field with no validation.
---
### `DiscoveryModal.vue`
Auto-discovery modal: ping sweep + DNS PTR lookup.
**Props**: `vlans[]`
**Emits**: `close`, `import(hosts[])` — parent handles inserting discovered hosts
Lets the user select VLANs to scan and configure the DNS server. Shows progress and results. Discovered hosts can be selectively imported.
---
## CSS Conventions
All background, text, and border colors use CSS custom properties from `App.vue`:
```
--bg-page, --bg-card, --bg-card-hover, --bg-input
--bg-thead, --bg-chip, --bg-chip-hover
--border, --border-strong
--text-primary, --text-secondary, --text-muted, --text-faint
--chip-ip-bg, --chip-ip-color
--modal-overlay, --shadow-card, --shadow-modal, --shadow-drop
```
**Exceptions** (hardcoded hex is acceptable):
- Device type icon colors in chips (semantic, theme-independent)
- VLAN badge background colors
- Auth feedback colors (`#EF4444` red, `#15803D` green) with dark overrides via `:global(html.dark .class)`
**Dark-mode scoped override syntax**:
```css
/* CORRECT */
:global(html.dark .my-component .child) { color: red; }
/* WRONG — Vue does not resolve descendants after :global() closing paren */
:global(html.dark) .my-component .child { color: red; }
```
+22
View File
@@ -0,0 +1,22 @@
# Documentation
## Contents
- [architecture.md](architecture.md) — System overview, Docker setup, request flow, startup sequence
- [backend.md](backend.md) — All API endpoints, ORM models, auth system, migration patterns
- [frontend.md](frontend.md) — Vue 3 module inventory, component reference, CSS conventions
- [data-model.md](data-model.md) — Database schema, relationships, device types, migration patterns
- [extending.md](extending.md) — Cookbooks: add a device field, API route, i18n key, brand icon, component
## Quick Reference
| Task | File |
|------|------|
| Add a field to Device | [extending.md → Add a Field to Device](extending.md#add-a-field-to-device) |
| Add an API endpoint | [extending.md → Add a New API Route Group](extending.md#add-a-new-api-route-group) |
| Add a translation key | [extending.md → Add a New i18n Key](extending.md#add-a-new-i18n-key) |
| Add a brand icon | [extending.md → Add a Brand Icon](extending.md#add-a-brand-icon) |
| Reset admin password | [extending.md → Change the Default Admin Password](extending.md#change-the-default-admin-password) |
| API endpoint reference | [backend.md → API Endpoints](backend.md#api-endpoints) |
| CSS variables list | [frontend.md → CSS Conventions](frontend.md#css-conventions) |
| Database schema | [data-model.md → Schema Overview](data-model.md#schema-overview) |