From fbb6138c28c2c947ffb899b9d036c25607865ae3 Mon Sep 17 00:00:00 2001 From: Mathieu BOURBON Date: Sat, 18 Apr 2026 16:24:44 +0200 Subject: [PATCH] first commit --- .dockerignore | 15 ++ .env.example | 52 +++++ .eslintrc.json | 10 + .gitignore | 54 +++++ .prettierrc | 8 + ARCHITECTURE.md | 147 ++++++++++++ README.md | 91 ++++++++ docker/Dockerfile | 44 ++++ docker/docker-compose.yml | 61 +++++ next.config.mjs | 28 +++ package.json | 74 ++++++ postcss.config.mjs | 6 + prisma/schema.prisma | 213 ++++++++++++++++++ prisma/seed.ts | 62 +++++ scripts/run-discovery.ts | 37 +++ src/app/(dashboard)/applications/page.tsx | 91 ++++++++ src/app/(dashboard)/hosts/page.tsx | 123 ++++++++++ src/app/(dashboard)/layout.tsx | 5 + src/app/(dashboard)/networks/page.tsx | 81 +++++++ src/app/(dashboard)/scans/page.tsx | 106 +++++++++ src/app/(dashboard)/settings/page.tsx | 70 ++++++ src/app/api/discovery/route.ts | 32 +++ src/app/api/hosts/[id]/route.ts | 32 +++ src/app/api/hosts/route.ts | 26 +++ src/app/layout.tsx | 47 ++++ src/app/page.tsx | 181 +++++++++++++++ src/components/layout/header.tsx | 42 ++++ src/components/layout/page-container.tsx | 20 ++ src/components/layout/sidebar.tsx | 70 ++++++ src/components/scans/discovery-launcher.tsx | 104 +++++++++ src/components/theme/theme-provider.tsx | 67 ++++++ src/components/theme/theme-toggle.tsx | 45 ++++ src/components/ui/badge.tsx | 33 +++ src/components/ui/button.tsx | 55 +++++ src/components/ui/card.tsx | 61 +++++ src/components/ui/empty-state.tsx | 34 +++ src/components/ui/input.tsx | 21 ++ src/components/ui/label.tsx | 20 ++ src/components/ui/separator.tsx | 20 ++ src/components/ui/table.tsx | 70 ++++++ src/config/env.ts | 44 ++++ src/lib/api/response.ts | 41 ++++ src/lib/db/prisma.ts | 20 ++ src/lib/utils/cn.ts | 7 + src/lib/utils/logger.ts | 11 + .../applications/applications.schema.ts | 13 ++ .../applications/applications.service.ts | 29 +++ src/modules/applications/index.ts | 2 + src/modules/discovery/discovery.service.ts | 177 +++++++++++++++ src/modules/discovery/index.ts | 3 + src/modules/discovery/registry.ts | 26 +++ src/modules/discovery/scanners/arp.scanner.ts | 61 +++++ .../discovery/scanners/mdns.scanner.ts | 40 ++++ .../discovery/scanners/ping.scanner.ts | 49 ++++ .../discovery/scanners/port.scanner.ts | 75 ++++++ src/modules/discovery/types.ts | 51 +++++ src/modules/discovery/utils/concurrency.ts | 24 ++ src/modules/hosts/hosts.repository.ts | 58 +++++ src/modules/hosts/hosts.schema.ts | 27 +++ src/modules/hosts/hosts.service.ts | 25 ++ src/modules/hosts/index.ts | 2 + src/modules/networks/index.ts | 2 + src/modules/networks/networks.schema.ts | 20 ++ src/modules/networks/networks.service.ts | 18 ++ src/modules/ports/index.ts | 2 + src/modules/ports/ports.schema.ts | 18 ++ src/modules/ports/ports.service.ts | 24 ++ src/modules/scans/index.ts | 1 + src/modules/scans/scans.service.ts | 25 ++ src/styles/globals.css | 123 ++++++++++ tailwind.config.ts | 93 ++++++++ tsconfig.json | 40 ++++ 72 files changed, 3509 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 ARCHITECTURE.md create mode 100644 README.md create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 scripts/run-discovery.ts create mode 100644 src/app/(dashboard)/applications/page.tsx create mode 100644 src/app/(dashboard)/hosts/page.tsx create mode 100644 src/app/(dashboard)/layout.tsx create mode 100644 src/app/(dashboard)/networks/page.tsx create mode 100644 src/app/(dashboard)/scans/page.tsx create mode 100644 src/app/(dashboard)/settings/page.tsx create mode 100644 src/app/api/discovery/route.ts create mode 100644 src/app/api/hosts/[id]/route.ts create mode 100644 src/app/api/hosts/route.ts create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.tsx create mode 100644 src/components/layout/header.tsx create mode 100644 src/components/layout/page-container.tsx create mode 100644 src/components/layout/sidebar.tsx create mode 100644 src/components/scans/discovery-launcher.tsx create mode 100644 src/components/theme/theme-provider.tsx create mode 100644 src/components/theme/theme-toggle.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/button.tsx create mode 100644 src/components/ui/card.tsx create mode 100644 src/components/ui/empty-state.tsx create mode 100644 src/components/ui/input.tsx create mode 100644 src/components/ui/label.tsx create mode 100644 src/components/ui/separator.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/config/env.ts create mode 100644 src/lib/api/response.ts create mode 100644 src/lib/db/prisma.ts create mode 100644 src/lib/utils/cn.ts create mode 100644 src/lib/utils/logger.ts create mode 100644 src/modules/applications/applications.schema.ts create mode 100644 src/modules/applications/applications.service.ts create mode 100644 src/modules/applications/index.ts create mode 100644 src/modules/discovery/discovery.service.ts create mode 100644 src/modules/discovery/index.ts create mode 100644 src/modules/discovery/registry.ts create mode 100644 src/modules/discovery/scanners/arp.scanner.ts create mode 100644 src/modules/discovery/scanners/mdns.scanner.ts create mode 100644 src/modules/discovery/scanners/ping.scanner.ts create mode 100644 src/modules/discovery/scanners/port.scanner.ts create mode 100644 src/modules/discovery/types.ts create mode 100644 src/modules/discovery/utils/concurrency.ts create mode 100644 src/modules/hosts/hosts.repository.ts create mode 100644 src/modules/hosts/hosts.schema.ts create mode 100644 src/modules/hosts/hosts.service.ts create mode 100644 src/modules/hosts/index.ts create mode 100644 src/modules/networks/index.ts create mode 100644 src/modules/networks/networks.schema.ts create mode 100644 src/modules/networks/networks.service.ts create mode 100644 src/modules/ports/index.ts create mode 100644 src/modules/ports/ports.schema.ts create mode 100644 src/modules/ports/ports.service.ts create mode 100644 src/modules/scans/index.ts create mode 100644 src/modules/scans/scans.service.ts create mode 100644 src/styles/globals.css create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..07c1bc1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +.next +.git +.env +.env.local +.env.*.local +npm-debug.log +Dockerfile +.dockerignore +.vscode +.idea +tests +coverage +*.md +!README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3ddf0ff --- /dev/null +++ b/.env.example @@ -0,0 +1,52 @@ +# ---------------------------------------- +# IPAM — Variables d'environnement +# ---------------------------------------- + +# Environnement d'exécution +NODE_ENV=development + +# ---------------------------------------- +# Base de données +# ---------------------------------------- +# Choisir le provider : "sqlite" ou "postgresql" +DATABASE_PROVIDER=sqlite + +# SQLite (par défaut, aucun serveur à installer) +DATABASE_URL="file:./data/ipam.db" + +# PostgreSQL (décommenter et adapter) +# DATABASE_PROVIDER=postgresql +# DATABASE_URL="postgresql://ipam:ipam@localhost:5432/ipam?schema=public" + +# ---------------------------------------- +# Application +# ---------------------------------------- +PORT=3000 +APP_URL=http://localhost:3000 + +# ---------------------------------------- +# Découverte réseau +# ---------------------------------------- +# Plages CIDR scannées par défaut (séparées par des virgules) +DISCOVERY_DEFAULT_CIDRS=192.168.1.0/24 + +# Ports scannés par défaut (communs homelab) +DISCOVERY_DEFAULT_PORTS=22,53,80,81,443,445,3000,3306,5432,6379,7878,8080,8081,8096,8123,8443,8989,9000,9090,9091,9117,9443,32400 + +# Timeout ping en ms +DISCOVERY_PING_TIMEOUT=1000 +# Timeout scan de port en ms +DISCOVERY_PORT_TIMEOUT=800 +# Concurrence des scans (nombre d'hôtes scannés en parallèle) +DISCOVERY_CONCURRENCY=32 + +# Activer/désactiver chaque scanner +DISCOVERY_ENABLE_PING=true +DISCOVERY_ENABLE_PORT_SCAN=true +DISCOVERY_ENABLE_ARP=true +DISCOVERY_ENABLE_MDNS=true + +# ---------------------------------------- +# Logs +# ---------------------------------------- +LOG_LEVEL=info diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..e8af991 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "extends": ["next/core-web-vitals", "next/typescript"], + "rules": { + "@typescript-eslint/no-unused-vars": [ + "warn", + { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" } + ], + "@typescript-eslint/consistent-type-imports": "warn" + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fc3b11 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Build +.next/ +out/ +dist/ +build/ + +# Next.js +next-env.d.ts + +# Env +.env +.env.local +.env.production +.env.development.local +.env.test.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Prisma +prisma/data/ +prisma/*.db +prisma/*.db-journal +data/ +*.db +*.db-journal + +# Docker +.docker/data/ + +# Tests / Coverage +coverage/ +.nyc_output/ + +# TypeScript +*.tsbuildinfo diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..fdad3f0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..68dfb4d --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,147 @@ +# Architecture + +Ce document décrit la structure du projet et les principes qui la gouvernent, +afin de garder l'application **évolutive** (facile à étendre, sans dette). + +## Principes + +1. **Modular monolith** — un seul projet Next.js, mais des modules métier isolés + sous `src/modules//`. Chaque module expose son `service` ; les routes + API et l'UI n'accèdent JAMAIS directement à Prisma pour un domaine. +2. **Validation à la frontière** — tout input externe (API, formulaire, scanner) + est validé via un schéma Zod avant d'atteindre le service. +3. **Registry pattern** pour les scanners — ajouter une capacité de découverte se + fait en implémentant une interface `Scanner` et en l'enregistrant dans le + registre (aucune autre partie du code n'est touchée). +4. **DB-agnostic** — Prisma lit `DATABASE_PROVIDER` et `DATABASE_URL` à l'exécution ; + SQLite pour commencer, PostgreSQL plus tard sans refactor. + +## Arborescence + +``` +IPAM/ +├── docker/ Dockerfile + docker-compose (profils sqlite/postgres) +├── prisma/ +│ ├── schema.prisma Modèle de données (Network, Host, Port, Application, Scan) +│ ├── migrations/ Migrations SQL (créées par `db:migrate`) +│ └── seed.ts Données d'exemple +├── public/ Assets statiques +├── scripts/ +│ └── run-discovery.ts CLI pour lancer une découverte hors UI +├── src/ +│ ├── app/ Next.js App Router +│ │ ├── (dashboard)/ Route group → layout UI commun (sidebar, header) +│ │ │ ├── hosts/ Pages de gestion des hôtes +│ │ │ ├── networks/ +│ │ │ ├── applications/ +│ │ │ ├── scans/ +│ │ │ └── settings/ +│ │ ├── api/ Route Handlers — FINS, délèguent aux services +│ │ │ ├── hosts/ +│ │ │ ├── networks/ +│ │ │ ├── applications/ +│ │ │ ├── ports/ +│ │ │ ├── scans/ +│ │ │ └── discovery/ +│ │ ├── layout.tsx +│ │ └── page.tsx +│ │ +│ ├── components/ Composants React +│ │ ├── ui/ Primitives (Button, Input, Dialog...) +│ │ ├── layout/ Sidebar, Header, Shell +│ │ ├── hosts/ networks/ ... Composants spécifiques par domaine +│ │ └── common/ Composants réutilisables (DataTable, Skeleton...) +│ │ +│ ├── modules/ ★ Cœur métier — UN DOSSIER PAR DOMAINE +│ │ ├── hosts/ +│ │ │ ├── hosts.schema.ts Validation Zod + types +│ │ │ ├── hosts.repository.ts Accès Prisma (seule frontière avec la DB) +│ │ │ ├── hosts.service.ts Logique métier +│ │ │ └── index.ts +│ │ ├── networks/ +│ │ ├── applications/ +│ │ ├── ports/ +│ │ ├── scans/ +│ │ └── discovery/ ★ Découverte réseau (pluggable) +│ │ ├── types.ts Interface `Scanner` + types résultats +│ │ ├── registry.ts Enregistrement des scanners +│ │ ├── discovery.service.ts Orchestrateur (persiste en DB) +│ │ ├── scanners/ +│ │ │ ├── ping.scanner.ts +│ │ │ ├── port.scanner.ts +│ │ │ ├── arp.scanner.ts +│ │ │ └── mdns.scanner.ts +│ │ └── utils/ +│ │ └── concurrency.ts Runner parallèle borné +│ │ +│ ├── lib/ Utilitaires transverses +│ │ ├── db/prisma.ts Client Prisma (singleton) +│ │ ├── api/response.ts Helpers NextResponse (ok/badRequest/...) +│ │ ├── utils/cn.ts Merge classes Tailwind +│ │ └── utils/logger.ts Pino logger configuré +│ │ +│ ├── config/env.ts Env vars typées via Zod (fail-fast) +│ ├── hooks/ Hooks React partagés (ex: useHosts) +│ ├── types/ Types globaux +│ └── styles/globals.css Tailwind + variables de thème +│ +├── tests/ Unitaires + intégration (vitest) +├── .env.example +├── next.config.mjs output: standalone + ext pkgs réseau +├── tsconfig.json Aliases @/modules, @/lib, @/components… +└── tailwind.config.ts +``` + +## Flux d'une requête + +``` +Client (RSC ou fetch) + → src/app/api//route.ts (validation + handleError) + → src/modules//service.ts (règles métier) + → src/modules//repository (Prisma) + → DB (SQLite | PostgreSQL) +``` + +## Ajouter un module métier (ex: VLAN) + +1. Créer `src/modules/vlans/` avec `vlans.schema.ts`, `vlans.service.ts`, `index.ts` +2. Ajouter le modèle Prisma dans `prisma/schema.prisma` → `npm run db:migrate` +3. Créer les routes `src/app/api/vlans/route.ts` (et `[id]/route.ts`) — copier le pattern des hôtes +4. Créer la page `src/app/(dashboard)/vlans/page.tsx` +5. (Optionnel) Ajouter des composants UI dans `src/components/vlans/` + +Aucun fichier existant ne doit être modifié en dehors de `schema.prisma`. + +## Ajouter un scanner de découverte (ex: SNMP) + +1. Créer `src/modules/discovery/scanners/snmp.scanner.ts` exportant une constante + conforme à l'interface `Scanner` (`kind`, `label`, `enabled`, `run`) +2. L'importer dans `src/modules/discovery/registry.ts` et l'ajouter au + `scannersRegistry` avec sa clé `ScannerKind` (mettre à jour le type union dans + `types.ts`) +3. Exposer la clé dans `bodySchema` de `src/app/api/discovery/route.ts` + +Il sera automatiquement disponible dans `runSingle()` et, si intégré à +`runFull()`, dans le scan complet. + +## Choix techniques clefs + +- **Prisma plutôt que Drizzle** : meilleures migrations pour un homelab, Studio + inclus. Drizzle serait envisageable plus tard si on a besoin de SQL brut. +- **App Router (pas Pages Router)** : server components, streaming, server + actions disponibles quand on voudra simplifier des formulaires. +- **Pas de framework "d'auth" pour l'instant** : ajouter NextAuth/Auth.js quand + on exposera l'IPAM au-delà du LAN. Un emplacement naturel : `src/modules/auth/`. +- **Pas de Redis/queue** : tous les scans tournent dans le process. À prévoir + (BullMQ + Redis) uniquement si on planifie des scans récurrents lourds. + +## Futures fonctionnalités envisagées + +- Scans planifiés (cron-like, via BullMQ ou `node-cron`) +- Notifications (nouvel hôte, port qui change d'état) +- Export CSV / JSON des inventaires +- Synchronisation avec un reverse-proxy (Traefik, Nginx Proxy Manager) pour + auto-remplir les applications +- API d'OUI lookup pour déduire le constructeur depuis la MAC +- Authentification multi-utilisateur + RBAC +- Topologie graphique (D3 ou React Flow) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6e470a --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# IPAM Homelab + +Application web **TypeScript / Next.js 15** pour gérer l'inventaire IP d'un homelab : hôtes, ports, applications, avec **découverte réseau automatique** (ping sweep, scan de ports, ARP, mDNS). + +## Stack + +- **Next.js 15** (App Router) + **React 18** + **TypeScript** +- **Prisma** ORM avec support **SQLite _ou_ PostgreSQL** (via variable d'env) +- **TailwindCSS** + design system inspiré shadcn/ui +- **Zod** pour la validation +- **Pino** pour les logs +- **Docker / Docker Compose** pour le déploiement + +## Démarrage rapide + +```bash +# 1. Installer les dépendances +npm install + +# 2. Copier la configuration d'exemple +cp .env.example .env + +# 3. Générer le client Prisma et créer la DB +npm run db:generate +npm run db:migrate + +# 4. (Optionnel) Charger des données d'exemple +npm run db:seed + +# 5. Lancer en développement +npm run dev +``` + +Accès : http://localhost:3000 + +## Passer de SQLite à PostgreSQL + +Dans `.env` : + +```env +DATABASE_PROVIDER=postgresql +DATABASE_URL="postgresql://ipam:ipam@localhost:5432/ipam?schema=public" +``` + +Puis : + +```bash +npm run db:generate +npm run db:migrate +``` + +## Déploiement Docker + +```bash +# Avec SQLite (simple, fichier local persisté dans un volume) +docker compose --profile sqlite -f docker/docker-compose.yml up -d + +# Avec PostgreSQL +docker compose --profile postgres -f docker/docker-compose.yml up -d +``` + +> Le conteneur utilise `network_mode: host` pour permettre les scans ARP / mDNS / +> ping sur le LAN. À désactiver si non nécessaire. + +## Découverte réseau + +| Scanner | Description | Requiert | +| ------- | ---------------------------------------------------- | ---------------------------------- | +| `ping` | Ping sweep ICMP sur un CIDR | `cap_net_raw` ou bin `ping` | +| `port` | Scan TCP connect sur une IP | — | +| `arp` | Lit la table ARP locale (`ip neigh` / `arp -a`) | Accès au LAN (host network) | +| `mdns` | Écoute Bonjour / DNS-SD | Multicast sur le LAN | + +Lancer un scan complet via CLI : + +```bash +npm run discovery:run -- --kind full --cidr 192.168.1.0/24 +``` + +Via l'API : + +```bash +curl -X POST http://localhost:3000/api/discovery \ + -H 'Content-Type: application/json' \ + -d '{"kind":"full","cidr":"192.168.1.0/24"}' +``` + +## Architecture + +Voir [`ARCHITECTURE.md`](./ARCHITECTURE.md) pour le détail des couches et la +procédure d'ajout d'un nouveau module métier ou d'un nouveau scanner. diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..a709ce2 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.6 +# ---------- Stage 1 : deps ---------- +FROM node:20-alpine AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +# ---------- Stage 2 : build ---------- +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +RUN npm run build + +# ---------- Stage 3 : runner ---------- +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=3000 + +# Outils système utiles à la découverte réseau +# - iputils : ping ICMP (privileged / cap_net_raw) +# - iproute2 : ip neigh (ARP) +# - nmap : fallback scan ports (optionnel, décommente si besoin) +RUN apk add --no-cache iputils iproute2 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma + +USER nextjs + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..d1d8b08 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,61 @@ +# --------------------------------------------------------------- +# IPAM — Docker Compose +# --------------------------------------------------------------- +# Deux profils : +# - `sqlite` → ipam seul, base dans un volume +# - `postgres` → ipam + postgres +# +# Usage : +# docker compose --profile sqlite up -d +# docker compose --profile postgres up -d +# --------------------------------------------------------------- + +services: + ipam: + build: + context: .. + dockerfile: docker/Dockerfile + container_name: ipam + restart: unless-stopped + # Network mode host : indispensable pour ARP / mDNS / ping sweep + # sur le réseau local. Commenter cette ligne si non nécessaire. + network_mode: host + environment: + NODE_ENV: production + PORT: 3000 + env_file: + - ../.env + volumes: + - ipam-data:/app/data + # Capabilities requises pour le ping ICMP raw + cap_add: + - NET_RAW + - NET_ADMIN + profiles: [sqlite, postgres] + depends_on: + postgres: + condition: service_healthy + required: false + + postgres: + image: postgres:16-alpine + container_name: ipam-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ipam + POSTGRES_PASSWORD: ipam + POSTGRES_DB: ipam + volumes: + - ipam-postgres:/var/lib/postgresql/data + ports: + - '5432:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ipam'] + interval: 5s + timeout: 3s + retries: 10 + profiles: [postgres] + +volumes: + ipam-data: + ipam-postgres: diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..b810137 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,28 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + output: 'standalone', // Optimisé pour Docker + experimental: { + // Permet l'utilisation de paquets natifs côté serveur (ping, bonjour, etc.) + serverComponentsExternalPackages: [ + 'ping', + 'bonjour-service', + '@prisma/client', + ], + }, + // Empêche le bundling des modules réseau côté client + webpack: (config, { isServer }) => { + if (!isServer) { + config.resolve.fallback = { + ...config.resolve.fallback, + net: false, + dns: false, + fs: false, + child_process: false, + }; + } + return config; + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..7609b24 --- /dev/null +++ b/package.json @@ -0,0 +1,74 @@ +{ + "name": "ipam-homelab", + "version": "0.1.0", + "private": true, + "description": "IPAM (IP Address Management) pour homelab — inventaire, ports, applications et découverte réseau", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md}\"", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "db:generate": "prisma generate", + "db:migrate": "prisma migrate dev", + "db:deploy": "prisma migrate deploy", + "db:studio": "prisma studio", + "db:seed": "tsx prisma/seed.ts", + "db:reset": "prisma migrate reset", + "docker:up": "docker compose -f docker/docker-compose.yml up -d", + "docker:down": "docker compose -f docker/docker-compose.yml down", + "docker:logs": "docker compose -f docker/docker-compose.yml logs -f", + "discovery:run": "tsx scripts/run-discovery.ts" + }, + "dependencies": { + "@hookform/resolvers": "^3.9.0", + "@prisma/client": "^5.22.0", + "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.2", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.2", + "@tanstack/react-query": "^5.59.0", + "bonjour-service": "^1.2.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "ip-address": "^9.0.5", + "ip-regex": "^5.0.0", + "lucide-react": "^0.454.0", + "netmask": "^2.0.2", + "next": "^15.0.0", + "ping": "^0.4.4", + "pino": "^9.5.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.53.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/netmask": "^2.0.5", + "@types/node": "^22.7.0", + "@types/ping": "^0.4.4", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.20", + "eslint": "^9.12.0", + "eslint-config-next": "^15.0.0", + "postcss": "^8.4.47", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.6.8", + "prisma": "^5.22.0", + "tailwindcss": "^3.4.13", + "tsx": "^4.19.1", + "typescript": "^5.6.3", + "vitest": "^2.1.2" + }, + "engines": { + "node": ">=20" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..f323da1 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,213 @@ +// IPAM — Schéma Prisma +// --------------------------------------------------------------------- +// Le `provider` est injecté via la variable d'environnement DATABASE_PROVIDER +// (SQLite par défaut, PostgreSQL en option). Les deux sont pris en charge. +// Lors d'un changement de provider, régénérer le client : `pnpm db:generate` +// et lancer une nouvelle migration : `pnpm db:migrate`. +// --------------------------------------------------------------------- + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = env("DATABASE_PROVIDER") + url = env("DATABASE_URL") +} + +// ===================================================================== +// Réseaux (subnets / VLAN) +// ===================================================================== +model Network { + id String @id @default(cuid()) + name String + cidr String @unique // ex: "192.168.1.0/24" + description String? + vlanId Int? + gateway String? + dnsServers String? // CSV — compat SQLite (pas de array natif) + color String? // Couleur d'affichage UI + + hosts Host[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([cidr]) +} + +// ===================================================================== +// Hôtes (une IP = un hôte) +// ===================================================================== +model Host { + id String @id @default(cuid()) + ipAddress String @unique // IPv4/IPv6 + hostname String? + macAddress String? + description String? + vendor String? // Issue du MAC (OUI) ou d'une fingerprint + osGuess String? // OS détecté / renseigné + + status HostStatus @default(UNKNOWN) + + // Origine de la fiche : saisie manuelle ou découverte + source HostSource @default(MANUAL) + + // Relations + networkId String? + network Network? @relation(fields: [networkId], references: [id], onDelete: SetNull) + + ports Port[] + applications HostApplication[] + scanResults ScanResult[] + + lastSeenAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([ipAddress]) + @@index([networkId]) + @@index([status]) +} + +enum HostStatus { + UP + DOWN + UNKNOWN +} + +enum HostSource { + MANUAL + DISCOVERED + IMPORTED +} + +// ===================================================================== +// Ports ouverts sur un hôte +// ===================================================================== +model Port { + id String @id @default(cuid()) + number Int + protocol Protocol @default(TCP) + serviceName String? // ex: "http", "ssh", renseigné ou deviné + banner String? // Banner applicatif collecté + state PortState @default(OPEN) + + hostId String + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + + // Lien éventuel vers une application métier déclarée + applicationId String? + application Application? @relation(fields: [applicationId], references: [id], onDelete: SetNull) + + lastCheckedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([hostId, number, protocol]) + @@index([hostId]) + @@index([number]) +} + +enum Protocol { + TCP + UDP +} + +enum PortState { + OPEN + CLOSED + FILTERED + UNKNOWN +} + +// ===================================================================== +// Applications (Jellyfin, Home Assistant, Nextcloud, etc.) +// ===================================================================== +model Application { + id String @id @default(cuid()) + name String @unique + description String? + category String? // ex: "media", "monitoring", "storage" + icon String? // URL ou clé d'icône + url String? // URL principale d'accès + + hosts HostApplication[] + ports Port[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Table de jointure Hôte <-> Application (N:N) +model HostApplication { + hostId String + applicationId String + + host Host @relation(fields: [hostId], references: [id], onDelete: Cascade) + application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + notes String? + + createdAt DateTime @default(now()) + + @@id([hostId, applicationId]) +} + +// ===================================================================== +// Découverte réseau — historique des scans +// ===================================================================== +model Scan { + id String @id @default(cuid()) + type ScanType + status ScanStatus @default(PENDING) + target String // CIDR, IP, ou hôte + params String? // JSON (Zod validé côté app) + startedAt DateTime? + endedAt DateTime? + + hostsFound Int @default(0) + portsFound Int @default(0) + errorMessage String? + + results ScanResult[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([type]) +} + +enum ScanType { + PING + PORT + ARP + MDNS + FULL +} + +enum ScanStatus { + PENDING + RUNNING + COMPLETED + FAILED + CANCELLED +} + +// Résultat individuel d'un scan (rattaché à un hôte quand possible) +model ScanResult { + id String @id @default(cuid()) + scanId String + scan Scan @relation(fields: [scanId], references: [id], onDelete: Cascade) + + ipAddress String + hostId String? + host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull) + + data String // JSON (ports trouvés, services mDNS, MAC, etc.) + createdAt DateTime @default(now()) + + @@index([scanId]) + @@index([ipAddress]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..14c321f --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,62 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + // Réseau d'exemple + const network = await prisma.network.upsert({ + where: { cidr: '192.168.1.0/24' }, + update: {}, + create: { + name: 'Homelab LAN', + cidr: '192.168.1.0/24', + description: 'Réseau principal du homelab', + gateway: '192.168.1.1', + dnsServers: '1.1.1.1,9.9.9.9', + color: '#3b82f6', + }, + }); + + // Applications usuelles en homelab + const apps = [ + { name: 'Jellyfin', category: 'media', description: 'Serveur multimédia' }, + { name: 'Home Assistant', category: 'automation', description: 'Domotique' }, + { name: 'Nextcloud', category: 'storage', description: 'Cloud personnel' }, + { name: 'Pi-hole', category: 'network', description: 'Bloqueur DNS' }, + { name: 'Portainer', category: 'management', description: 'Gestion Docker' }, + { name: 'Grafana', category: 'monitoring', description: 'Dashboards' }, + ]; + + for (const app of apps) { + await prisma.application.upsert({ + where: { name: app.name }, + update: {}, + create: app, + }); + } + + // Hôte d'exemple + await prisma.host.upsert({ + where: { ipAddress: '192.168.1.1' }, + update: {}, + create: { + ipAddress: '192.168.1.1', + hostname: 'router', + description: 'Passerelle / routeur du homelab', + status: 'UP', + source: 'MANUAL', + networkId: network.id, + }, + }); + + console.log('✅ Seed terminé'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/scripts/run-discovery.ts b/scripts/run-discovery.ts new file mode 100644 index 0000000..4af5d91 --- /dev/null +++ b/scripts/run-discovery.ts @@ -0,0 +1,37 @@ +/** + * CLI pour lancer une découverte sans passer par l'UI. + * Utilisation : `pnpm discovery:run -- --cidr 192.168.1.0/24` + */ +import { discoveryService } from '../src/modules/discovery'; + +function arg(name: string, fallback?: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + return idx > -1 ? process.argv[idx + 1] : fallback; +} + +async function main() { + const kind = (arg('kind', 'full') as 'full' | 'ping' | 'port' | 'arp' | 'mdns'); + const cidr = arg('cidr', '192.168.1.0/24'); + const ip = arg('ip'); + const portsRaw = arg('ports'); + const ports = portsRaw ? portsRaw.split(',').map(Number) : undefined; + + console.log(`▶ Discovery ${kind} sur ${cidr ?? ip}`); + + const scan = + kind === 'full' + ? await discoveryService.runFull(cidr!, ports) + : await discoveryService.runSingle(kind, { cidr, ip, ports }); + + console.log('✅ Scan terminé', { + id: scan.id, + status: scan.status, + hostsFound: scan.hostsFound, + portsFound: scan.portsFound, + }); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/app/(dashboard)/applications/page.tsx b/src/app/(dashboard)/applications/page.tsx new file mode 100644 index 0000000..e1df759 --- /dev/null +++ b/src/app/(dashboard)/applications/page.tsx @@ -0,0 +1,91 @@ +import { AppWindow, ExternalLink, Plus } from 'lucide-react'; +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { prisma } from '@/lib/db/prisma'; + +async function getApplications() { + try { + return await prisma.application.findMany({ + include: { _count: { select: { hosts: true, ports: true } } }, + orderBy: { name: 'asc' }, + }); + } catch { + return []; + } +} + +export default async function ApplicationsPage() { + const apps = await getApplications(); + + return ( + <> +
+ + Nouvelle application + + } + /> + + {apps.length === 0 ? ( + Ajouter une application} + /> + ) : ( +
+ {apps.map((a) => ( + +
+
+ {a.icon ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+ {a.category && {a.category}} +
+
+

{a.name}

+ {a.description && ( +

+ {a.description} +

+ )} +
+
+ + {a._count.hosts} hôte{a._count.hosts > 1 ? 's' : ''} ·{' '} + {a._count.ports} port{a._count.ports > 1 ? 's' : ''} + + {a.url && ( + + Ouvrir + + + )} +
+
+ ))} +
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/hosts/page.tsx b/src/app/(dashboard)/hosts/page.tsx new file mode 100644 index 0000000..65449b0 --- /dev/null +++ b/src/app/(dashboard)/hosts/page.tsx @@ -0,0 +1,123 @@ +import { Plus, Server } from 'lucide-react'; +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { prisma } from '@/lib/db/prisma'; + +async function getHosts() { + try { + return await prisma.host.findMany({ + include: { + network: true, + ports: { where: { state: 'OPEN' } }, + applications: { include: { application: true } }, + }, + orderBy: { ipAddress: 'asc' }, + }); + } catch { + return []; + } +} + +const statusVariant = { + UP: 'success', + DOWN: 'destructive', + UNKNOWN: 'default', +} as const; + +export default async function HostsPage() { + const hosts = await getHosts(); + + return ( + <> +
+ + Ajouter un hôte + + } + /> + + {hosts.length === 0 ? ( + Lancer une découverte} + /> + ) : ( + + + + + Adresse IP + Nom + Réseau + Statut + Ports + Applications + Dernière vue + + + + {hosts.map((h) => ( + + {h.ipAddress} + {h.hostname ?? '—'} + + {h.network?.name ?? '—'} + + + {h.status} + + + {h.ports.length > 0 ? ( + {h.ports.length} + ) : ( + '—' + )} + + +
+ {h.applications.slice(0, 3).map((ha) => ( + + {ha.application.name} + + ))} + {h.applications.length > 3 && ( + +{h.applications.length - 3} + )} + {h.applications.length === 0 && ( + + )} +
+
+ + {h.lastSeenAt + ? new Date(h.lastSeenAt).toLocaleString('fr-FR') + : '—'} + +
+ ))} +
+
+
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..908038c --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -0,0 +1,5 @@ +// Route group conservé pour organiser les pages du dashboard ; +// le sidebar est monté dans le layout racine pour éviter les conflits de routing. +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/src/app/(dashboard)/networks/page.tsx b/src/app/(dashboard)/networks/page.tsx new file mode 100644 index 0000000..9a126cc --- /dev/null +++ b/src/app/(dashboard)/networks/page.tsx @@ -0,0 +1,81 @@ +import { Network as NetworkIcon, Plus } from 'lucide-react'; +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { prisma } from '@/lib/db/prisma'; + +async function getNetworks() { + try { + return await prisma.network.findMany({ + include: { _count: { select: { hosts: true } } }, + orderBy: { name: 'asc' }, + }); + } catch { + return []; + } +} + +export default async function NetworksPage() { + const networks = await getNetworks(); + + return ( + <> +
+ + Nouveau réseau + + } + /> + + {networks.length === 0 ? ( + Créer un réseau} + /> + ) : ( +
+ {networks.map((n) => ( + +
+
+ +
+ {n.vlanId !== null && n.vlanId !== undefined && ( + VLAN {n.vlanId} + )} +
+
+

{n.name}

+

{n.cidr}

+ {n.description && ( +

{n.description}

+ )} +
+
+ Hôtes + {n._count.hosts} +
+
+ ))} +
+ )} +
+ + ); +} diff --git a/src/app/(dashboard)/scans/page.tsx b/src/app/(dashboard)/scans/page.tsx new file mode 100644 index 0000000..e19f271 --- /dev/null +++ b/src/app/(dashboard)/scans/page.tsx @@ -0,0 +1,106 @@ +import { Radar } from 'lucide-react'; +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { EmptyState } from '@/components/ui/empty-state'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { DiscoveryLauncher } from '@/components/scans/discovery-launcher'; +import { prisma } from '@/lib/db/prisma'; + +async function getScans() { + try { + return await prisma.scan.findMany({ + orderBy: { createdAt: 'desc' }, + take: 50, + }); + } catch { + return []; + } +} + +const statusVariant = { + COMPLETED: 'success', + RUNNING: 'info', + PENDING: 'default', + FAILED: 'destructive', + CANCELLED: 'warning', +} as const; + +function formatDuration(start: Date | null, end: Date | null) { + if (!start || !end) return '—'; + const diff = Math.max(0, end.getTime() - start.getTime()); + if (diff < 1000) return `${diff} ms`; + if (diff < 60_000) return `${(diff / 1000).toFixed(1)} s`; + return `${Math.floor(diff / 60_000)} min ${Math.floor((diff % 60_000) / 1000)} s`; +} + +export default async function ScansPage() { + const scans = await getScans(); + + return ( + <> +
+ + + +
+

+ Historique +

+ {scans.length === 0 ? ( + + ) : ( + + + + + Type + Cible + Statut + Hôtes + Ports + Durée + Date + + + + {scans.map((s) => ( + + + {s.type} + + {s.target} + + {s.status} + + {s.hostsFound} + {s.portsFound} + + {formatDuration(s.startedAt, s.endedAt)} + + + {new Date(s.createdAt).toLocaleString('fr-FR')} + + + ))} + +
+
+ )} +
+
+ + ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..40bfa5e --- /dev/null +++ b/src/app/(dashboard)/settings/page.tsx @@ -0,0 +1,70 @@ +import { Header } from '@/components/layout/header'; +import { PageContainer } from '@/components/layout/page-container'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { ThemeToggle } from '@/components/theme/theme-toggle'; + +export default function SettingsPage() { + return ( + <> +
+ + + + Apparence + Choisissez le thème de l’interface + + +
+
+

Thème

+

+ Automatique (système), clair ou sombre. +

+
+ +
+
+
+ + + + Découverte réseau + + Les valeurs par défaut sont lues depuis le fichier .env + (variables DISCOVERY_*). + + + + + + + + + + + + + + À propos + IPAM Homelab · version 0.1.0 + + + Projet open-source pour gérer l’inventaire IP de votre homelab. + + +
+ + ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/src/app/api/discovery/route.ts b/src/app/api/discovery/route.ts new file mode 100644 index 0000000..20bb851 --- /dev/null +++ b/src/app/api/discovery/route.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from 'next/server'; +import { z } from 'zod'; +import { discoveryService } from '@/modules/discovery'; +import { handleError, ok } from '@/lib/api/response'; + +const bodySchema = z.object({ + kind: z.enum(['ping', 'port', 'arp', 'mdns', 'full']), + cidr: z.string().optional(), + ip: z.string().optional(), + ports: z.array(z.number().int().min(1).max(65535)).optional(), +}); + +/** POST /api/discovery — lance un scan */ +export async function POST(req: NextRequest) { + try { + const { kind, cidr, ip, ports } = bodySchema.parse(await req.json()); + + const scan = + kind === 'full' + ? await discoveryService.runFull(cidr!, ports) + : await discoveryService.runSingle(kind, { cidr, ip, ports }); + + return ok(scan); + } catch (err) { + return handleError(err); + } +} + +/** GET /api/discovery — liste les scanners disponibles */ +export async function GET() { + return ok(discoveryService.listScanners()); +} diff --git a/src/app/api/hosts/[id]/route.ts b/src/app/api/hosts/[id]/route.ts new file mode 100644 index 0000000..6f3a50a --- /dev/null +++ b/src/app/api/hosts/[id]/route.ts @@ -0,0 +1,32 @@ +import type { NextRequest } from 'next/server'; +import { hostsService } from '@/modules/hosts'; +import { handleError, noContent, notFound, ok } from '@/lib/api/response'; + +export async function GET(_req: NextRequest, { params }: { params: { id: string } }) { + try { + const host = await hostsService.get(params.id); + if (!host) return notFound('Host not found'); + return ok(host); + } catch (err) { + return handleError(err); + } +} + +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + try { + const body = await req.json(); + const host = await hostsService.update(params.id, body); + return ok(host); + } catch (err) { + return handleError(err); + } +} + +export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) { + try { + await hostsService.delete(params.id); + return noContent(); + } catch (err) { + return handleError(err); + } +} diff --git a/src/app/api/hosts/route.ts b/src/app/api/hosts/route.ts new file mode 100644 index 0000000..baa900a --- /dev/null +++ b/src/app/api/hosts/route.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from 'next/server'; +import { hostsService } from '@/modules/hosts'; +import { handleError, ok, created } from '@/lib/api/response'; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const hosts = await hostsService.list({ + networkId: searchParams.get('networkId') ?? undefined, + status: searchParams.get('status') ?? undefined, + }); + return ok(hosts); + } catch (err) { + return handleError(err); + } +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const host = await hostsService.create(body); + return created(host); + } catch (err) { + return handleError(err); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..2581fb4 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from 'next'; +import { ThemeProvider } from '@/components/theme/theme-provider'; +import { Sidebar } from '@/components/layout/sidebar'; +import '@/styles/globals.css'; + +export const metadata: Metadata = { + title: 'IPAM Homelab', + description: 'Gestion des IP, ports et applications de votre homelab', +}; + +/** + * Script inline exécuté AVANT l'hydratation React pour appliquer le bon thème + * et éviter le flash (FOUC) côté client. + */ +const themeInitScript = ` +(function() { + try { + var stored = localStorage.getItem('ipam-theme'); + var theme = stored || 'system'; + var resolved = theme === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : theme; + var root = document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(resolved); + root.style.colorScheme = resolved; + } catch (_) {} +})(); +`; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + +