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:
@@ -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
@@ -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)`
|
||||
@@ -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" |
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
```
|
||||
@@ -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) |
|
||||
Reference in New Issue
Block a user