first commit

This commit is contained in:
Mathieu BOURBON
2026-04-18 16:24:44 +02:00
commit fbb6138c28
72 changed files with 3509 additions and 0 deletions

15
.dockerignore Normal file
View File

@@ -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

52
.env.example Normal file
View File

@@ -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

10
.eslintrc.json Normal file
View File

@@ -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"
}
}

54
.gitignore vendored Normal file
View File

@@ -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

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"plugins": ["prettier-plugin-tailwindcss"]
}

147
ARCHITECTURE.md Normal file
View File

@@ -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/<domaine>/`. 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/<domaine>/route.ts (validation + handleError)
→ src/modules/<domaine>/service.ts (règles métier)
→ src/modules/<domaine>/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)

91
README.md Normal file
View File

@@ -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.

44
docker/Dockerfile Normal file
View File

@@ -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"]

61
docker/docker-compose.yml Normal file
View File

@@ -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:

28
next.config.mjs Normal file
View File

@@ -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;

74
package.json Normal file
View File

@@ -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"
}
}

6
postcss.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

213
prisma/schema.prisma Normal file
View File

@@ -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])
}

62
prisma/seed.ts Normal file
View File

@@ -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();
});

37
scripts/run-discovery.ts Normal file
View File

@@ -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);
});

View File

@@ -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 (
<>
<Header
title="Applications"
description={`${apps.length} services`}
actions={
<Button size="sm">
<Plus className="h-4 w-4" />
Nouvelle application
</Button>
}
/>
<PageContainer>
{apps.length === 0 ? (
<EmptyState
icon={AppWindow}
title="Aucune application"
description="Ajoutez vos services homelab (Jellyfin, Home Assistant, Nextcloud…) et liez-les à leurs hôtes."
action={<Button size="sm">Ajouter une application</Button>}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{apps.map((a) => (
<Card key={a.id} className="flex flex-col p-6 transition-all hover:shadow-sm">
<div className="flex items-start justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
{a.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={a.icon} alt="" className="h-5 w-5 rounded" />
) : (
<AppWindow className="h-4 w-4 text-muted-foreground" strokeWidth={2} />
)}
</div>
{a.category && <Badge variant="outline">{a.category}</Badge>}
</div>
<div className="mt-5 flex flex-col gap-1">
<h3 className="text-base font-semibold">{a.name}</h3>
{a.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{a.description}
</p>
)}
</div>
<div className="mt-auto flex items-center justify-between border-t border-border pt-4 text-sm">
<span className="text-muted-foreground">
{a._count.hosts} hôte{a._count.hosts > 1 ? 's' : ''} ·{' '}
{a._count.ports} port{a._count.ports > 1 ? 's' : ''}
</span>
{a.url && (
<a
href={a.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
Ouvrir
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</Card>
))}
</div>
)}
</PageContainer>
</>
);
}

View File

@@ -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 (
<>
<Header
title="Hôtes"
description={`${hosts.length} machines recensées`}
actions={
<Button size="sm">
<Plus className="h-4 w-4" />
Ajouter un hôte
</Button>
}
/>
<PageContainer>
{hosts.length === 0 ? (
<EmptyState
icon={Server}
title="Aucun hôte"
description="Ajoutez un hôte manuellement ou lancez une découverte pour peupler votre inventaire automatiquement."
action={<Button size="sm">Lancer une découverte</Button>}
/>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Adresse IP</TableHead>
<TableHead>Nom</TableHead>
<TableHead>Réseau</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Ports</TableHead>
<TableHead>Applications</TableHead>
<TableHead className="text-right">Dernière vue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hosts.map((h) => (
<TableRow key={h.id}>
<TableCell className="font-mono text-sm">{h.ipAddress}</TableCell>
<TableCell className="font-medium">{h.hostname ?? '—'}</TableCell>
<TableCell className="text-muted-foreground">
{h.network?.name ?? '—'}
</TableCell>
<TableCell>
<Badge variant={statusVariant[h.status]}>{h.status}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{h.ports.length > 0 ? (
<span className="tabular-nums">{h.ports.length}</span>
) : (
'—'
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{h.applications.slice(0, 3).map((ha) => (
<Badge key={ha.applicationId} variant="outline">
{ha.application.name}
</Badge>
))}
{h.applications.length > 3 && (
<Badge variant="outline">+{h.applications.length - 3}</Badge>
)}
{h.applications.length === 0 && (
<span className="text-muted-foreground"></span>
)}
</div>
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">
{h.lastSeenAt
? new Date(h.lastSeenAt).toLocaleString('fr-FR')
: '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</PageContainer>
</>
);
}

View File

@@ -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}</>;
}

View File

@@ -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 (
<>
<Header
title="Réseaux"
description={`${networks.length} sous-réseaux`}
actions={
<Button size="sm">
<Plus className="h-4 w-4" />
Nouveau réseau
</Button>
}
/>
<PageContainer>
{networks.length === 0 ? (
<EmptyState
icon={NetworkIcon}
title="Aucun réseau"
description="Déclarez votre premier sous-réseau (ex: 192.168.1.0/24) pour organiser vos hôtes."
action={<Button size="sm">Créer un réseau</Button>}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{networks.map((n) => (
<Card key={n.id} className="p-6 transition-all hover:shadow-sm">
<div className="flex items-start justify-between">
<div
className="flex h-10 w-10 items-center justify-center rounded-xl"
style={{ backgroundColor: `${n.color ?? '#3b82f6'}20` }}
>
<NetworkIcon
className="h-4 w-4"
style={{ color: n.color ?? '#3b82f6' }}
strokeWidth={2}
/>
</div>
{n.vlanId !== null && n.vlanId !== undefined && (
<Badge variant="outline">VLAN {n.vlanId}</Badge>
)}
</div>
<div className="mt-5 flex flex-col gap-1">
<h3 className="text-base font-semibold">{n.name}</h3>
<p className="font-mono text-sm text-muted-foreground">{n.cidr}</p>
{n.description && (
<p className="mt-1 text-sm text-muted-foreground">{n.description}</p>
)}
</div>
<div className="mt-5 flex items-center justify-between border-t border-border pt-4 text-sm">
<span className="text-muted-foreground">Hôtes</span>
<span className="font-medium tabular-nums">{n._count.hosts}</span>
</div>
</Card>
))}
</div>
)}
</PageContainer>
</>
);
}

View File

@@ -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 (
<>
<Header title="Découverte" description="Scans récents du réseau" />
<PageContainer>
<DiscoveryLauncher />
<section>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">
Historique
</h2>
{scans.length === 0 ? (
<EmptyState
icon={Radar}
title="Aucun scan effectué"
description="Lancez votre premier scan ci-dessus pour découvrir automatiquement les hôtes et services de votre réseau."
/>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Cible</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Hôtes</TableHead>
<TableHead>Ports</TableHead>
<TableHead>Durée</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scans.map((s) => (
<TableRow key={s.id}>
<TableCell>
<Badge variant="outline">{s.type}</Badge>
</TableCell>
<TableCell className="font-mono text-sm">{s.target}</TableCell>
<TableCell>
<Badge variant={statusVariant[s.status]}>{s.status}</Badge>
</TableCell>
<TableCell className="tabular-nums">{s.hostsFound}</TableCell>
<TableCell className="tabular-nums">{s.portsFound}</TableCell>
<TableCell className="text-muted-foreground tabular-nums">
{formatDuration(s.startedAt, s.endedAt)}
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">
{new Date(s.createdAt).toLocaleString('fr-FR')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</section>
</PageContainer>
</>
);
}

View File

@@ -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 (
<>
<Header title="Réglages" description="Préférences de lapplication" />
<PageContainer>
<Card>
<CardHeader>
<CardTitle>Apparence</CardTitle>
<CardDescription>Choisissez le thème de linterface</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between py-2">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Thème</p>
<p className="text-xs text-muted-foreground">
Automatique (système), clair ou sombre.
</p>
</div>
<ThemeToggle />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Découverte réseau</CardTitle>
<CardDescription>
Les valeurs par défaut sont lues depuis le fichier <code>.env</code>
(variables <code>DISCOVERY_*</code>).
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Row label="Plages CIDR par défaut" value="Variable DISCOVERY_DEFAULT_CIDRS" />
<Separator />
<Row label="Ports scannés par défaut" value="Variable DISCOVERY_DEFAULT_PORTS" />
<Separator />
<Row label="Concurrence" value="Variable DISCOVERY_CONCURRENCY" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>À propos</CardTitle>
<CardDescription>IPAM Homelab · version 0.1.0</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Projet open-source pour gérer linventaire IP de votre homelab.
</CardContent>
</Card>
</PageContainer>
</>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between py-1">
<span className="text-sm">{label}</span>
<code className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{value}
</code>
</div>
);
}

View File

@@ -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());
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

47
src/app/layout.tsx Normal file
View File

@@ -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 (
<html lang="fr" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="min-h-screen bg-background font-sans antialiased">
<ThemeProvider>
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">{children}</div>
</div>
</ThemeProvider>
</body>
</html>
);
}

181
src/app/page.tsx Normal file
View File

@@ -0,0 +1,181 @@
import Link from 'next/link';
import { ArrowUpRight, AppWindow, Network, Radar, 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 { prisma } from '@/lib/db/prisma';
async function getStats() {
try {
const [hosts, hostsUp, networks, apps, openPorts, lastScan] = await Promise.all([
prisma.host.count(),
prisma.host.count({ where: { status: 'UP' } }),
prisma.network.count(),
prisma.application.count(),
prisma.port.count({ where: { state: 'OPEN' } }),
prisma.scan.findFirst({ orderBy: { createdAt: 'desc' } }),
]);
return { hosts, hostsUp, networks, apps, openPorts, lastScan };
} catch {
return { hosts: 0, hostsUp: 0, networks: 0, apps: 0, openPorts: 0, lastScan: null };
}
}
export default async function DashboardPage() {
const stats = await getStats();
const tiles = [
{
label: 'Hôtes',
value: stats.hosts,
hint: `${stats.hostsUp} en ligne`,
icon: Server,
href: '/hosts',
},
{
label: 'Réseaux',
value: stats.networks,
hint: 'Sous-réseaux suivis',
icon: Network,
href: '/networks',
},
{
label: 'Applications',
value: stats.apps,
hint: 'Services homelab',
icon: AppWindow,
href: '/applications',
},
{
label: 'Ports ouverts',
value: stats.openPorts,
hint: 'Tous hôtes confondus',
icon: Radar,
href: '/scans',
},
];
return (
<>
<Header
title="Vue densemble"
description="État de votre homelab"
actions={
<Button asChild size="sm">
<Link href="/scans">
<Radar className="h-4 w-4" />
Lancer une découverte
</Link>
</Button>
}
/>
<PageContainer>
<section className="flex flex-col gap-3">
<h2 className="text-4xl font-semibold tracking-tight">Bonjour 👋</h2>
<p className="text-muted-foreground">
Inventaire centralisé de vos IP, ports et applications, avec découverte
automatique du réseau.
</p>
</section>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{tiles.map(({ label, value, hint, icon: Icon, href }) => (
<Link key={label} href={href} className="group">
<Card className="p-5 transition-all hover:border-foreground/20 hover:shadow-sm">
<div className="flex items-start justify-between">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-muted">
<Icon className="h-4 w-4 text-muted-foreground" strokeWidth={2} />
</div>
<ArrowUpRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</div>
<div className="mt-5 flex flex-col gap-1">
<p className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="text-3xl font-semibold tabular-nums">{value}</p>
<p className="text-xs text-muted-foreground">{hint}</p>
</div>
</Card>
</Link>
))}
</section>
<section className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold">Dernier scan</h3>
<p className="text-sm text-muted-foreground">
Historique complet dans longlet Découverte
</p>
</div>
<Button asChild size="sm" variant="secondary">
<Link href="/scans">Voir tout</Link>
</Button>
</div>
<div className="mt-5">
{stats.lastScan ? (
<div className="flex items-center justify-between rounded-xl border border-border p-4">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{stats.lastScan.target}</p>
<p className="text-xs text-muted-foreground">
{stats.lastScan.type} · {new Date(stats.lastScan.createdAt).toLocaleString('fr-FR')}
</p>
</div>
<Badge
variant={
stats.lastScan.status === 'COMPLETED'
? 'success'
: stats.lastScan.status === 'FAILED'
? 'destructive'
: 'info'
}
>
{stats.lastScan.status}
</Badge>
</div>
) : (
<p className="text-sm text-muted-foreground">
Aucun scan pour le moment.
</p>
)}
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold">Raccourcis</h3>
<p className="text-sm text-muted-foreground">Actions fréquentes</p>
</div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<Button asChild variant="secondary" className="justify-start">
<Link href="/hosts">
<Server className="h-4 w-4" /> Ajouter un hôte
</Link>
</Button>
<Button asChild variant="secondary" className="justify-start">
<Link href="/networks">
<Network className="h-4 w-4" /> Ajouter un réseau
</Link>
</Button>
<Button asChild variant="secondary" className="justify-start">
<Link href="/applications">
<AppWindow className="h-4 w-4" /> Nouvelle app
</Link>
</Button>
<Button asChild variant="secondary" className="justify-start">
<Link href="/scans">
<Radar className="h-4 w-4" /> Scanner le LAN
</Link>
</Button>
</div>
</Card>
</section>
</PageContainer>
</>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import { Search } from 'lucide-react';
import { ThemeToggle } from '@/components/theme/theme-toggle';
interface HeaderProps {
title: string;
description?: string;
actions?: React.ReactNode;
}
/**
* Header sticky inspiré des barres d'outils macOS :
* titre à gauche, recherche + thème + actions à droite, effet frosted.
*/
export function Header({ title, description, actions }: HeaderProps) {
return (
<header className="glass sticky top-0 z-30 hairline">
<div className="flex h-14 items-center justify-between gap-6 px-6 md:px-8">
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold tracking-tight">{title}</h1>
{description && (
<p className="truncate text-xs text-muted-foreground">{description}</p>
)}
</div>
<div className="flex items-center gap-3">
<div className="focus-within:ring-ring focus-within:ring-1 hidden items-center gap-2 rounded-full border border-border bg-muted/40 px-3 py-1.5 md:flex">
<Search className="h-3.5 w-3.5 text-muted-foreground" />
<input
type="search"
placeholder="Rechercher…"
className="w-44 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<ThemeToggle />
{actions}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,20 @@
import { cn } from '@/lib/utils/cn';
export function PageContainer({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<main
className={cn(
'mx-auto flex w-full max-w-6xl flex-col gap-8 px-6 py-8 md:px-8 md:py-10 animate-fade-in',
className,
)}
>
{children}
</main>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Network,
Server,
AppWindow,
Radar,
Settings,
} from 'lucide-react';
import { cn } from '@/lib/utils/cn';
const navItems = [
{ href: '/', label: 'Vue densemble', icon: LayoutDashboard },
{ href: '/hosts', label: 'Hôtes', icon: Server },
{ href: '/networks', label: 'Réseaux', icon: Network },
{ href: '/applications', label: 'Applications', icon: AppWindow },
{ href: '/scans', label: 'Découverte', icon: Radar },
{ href: '/settings', label: 'Réglages', icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="sticky top-0 hidden h-screen w-60 shrink-0 border-r border-border bg-background/50 md:flex md:flex-col">
<div className="flex h-14 items-center gap-2 px-5">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
<Network className="h-4 w-4 text-primary" strokeWidth={2.25} />
</div>
<span className="text-sm font-semibold tracking-tight">IPAM</span>
</div>
<nav className="flex flex-1 flex-col gap-0.5 px-2.5 py-2">
{navItems.map(({ href, label, icon: Icon }) => {
const active =
href === '/' ? pathname === '/' : pathname?.startsWith(href);
return (
<Link
key={href}
href={href}
className={cn(
'group flex items-center gap-2.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
active
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground',
)}
>
<Icon
className={cn(
'h-4 w-4 transition-colors',
active ? 'text-primary' : 'text-muted-foreground group-hover:text-foreground',
)}
strokeWidth={2}
/>
{label}
</Link>
);
})}
</nav>
<div className="p-4 text-[11px] text-muted-foreground">
<p>IPAM Homelab</p>
<p className="opacity-60">v0.1.0</p>
</div>
</aside>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useState } from 'react';
import { Radar, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
type Kind = 'full' | 'ping' | 'port' | 'arp' | 'mdns';
export function DiscoveryLauncher() {
const [kind, setKind] = useState<Kind>('full');
const [cidr, setCidr] = useState('192.168.1.0/24');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const kinds: Array<{ value: Kind; label: string }> = [
{ value: 'full', label: 'Complet' },
{ value: 'ping', label: 'Ping' },
{ value: 'port', label: 'Ports' },
{ value: 'arp', label: 'ARP' },
{ value: 'mdns', label: 'mDNS' },
];
async function launch() {
setLoading(true);
setMessage(null);
try {
const res = await fetch('/api/discovery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind, cidr }),
});
const json = await res.json();
if (!res.ok) throw new Error(json?.error?.message ?? 'Échec du scan');
setMessage(
`✓ Scan ${json.data.status}${json.data.hostsFound} hôtes, ${json.data.portsFound} ports`,
);
} catch (e) {
setMessage(`${(e as Error).message}`);
} finally {
setLoading(false);
}
}
return (
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold">Nouvelle découverte</h3>
<p className="text-sm text-muted-foreground">
Scanner un sous-réseau à la recherche dhôtes et de services
</p>
</div>
<Radar className="h-5 w-5 text-muted-foreground" />
</div>
<div className="mt-5 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>Type de scan</Label>
<div className="inline-flex items-center rounded-full border border-border bg-muted/40 p-0.5">
{kinds.map((k) => (
<button
key={k.value}
onClick={() => setKind(k.value)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
kind === k.value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{k.label}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="cidr">Plage CIDR</Label>
<Input
id="cidr"
value={cidr}
onChange={(e) => setCidr(e.target.value)}
placeholder="192.168.1.0/24"
className="font-mono"
/>
</div>
<div className="flex items-center justify-between">
{message ? (
<p className="text-sm text-muted-foreground">{message}</p>
) : (
<span />
)}
<Button onClick={launch} disabled={loading} size="sm">
<Zap className="h-4 w-4" />
{loading ? 'Scan en cours…' : 'Lancer'}
</Button>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,67 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
export type Theme = 'light' | 'dark' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark';
setTheme: (t: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
const STORAGE_KEY = 'ipam-theme';
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>('system');
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
// Lecture initiale du storage
useEffect(() => {
const stored = (localStorage.getItem(STORAGE_KEY) as Theme | null) ?? 'system';
setThemeState(stored);
}, []);
// Application du thème au <html>
useEffect(() => {
const resolved = theme === 'system' ? getSystemTheme() : theme;
setResolvedTheme(resolved);
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(resolved);
root.style.colorScheme = resolved;
}, [theme]);
// Suivi dynamique du système si mode = system
useEffect(() => {
if (theme !== 'system') return;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => setResolvedTheme(mq.matches ? 'dark' : 'light');
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, [theme]);
const setTheme = (t: Theme) => {
localStorage.setItem(STORAGE_KEY, t);
setThemeState(t);
};
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}

View File

@@ -0,0 +1,45 @@
'use client';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useTheme, type Theme } from './theme-provider';
import { cn } from '@/lib/utils/cn';
/**
* Toggle segmenté à 3 états (Apple-style) : System / Light / Dark.
*/
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const options: Array<{ value: Theme; label: string; icon: typeof Sun }> = [
{ value: 'system', label: 'Système', icon: Monitor },
{ value: 'light', label: 'Clair', icon: Sun },
{ value: 'dark', label: 'Sombre', icon: Moon },
];
return (
<div
role="radiogroup"
aria-label="Thème"
className="inline-flex items-center rounded-full border border-border bg-muted/50 p-0.5"
>
{options.map(({ value, label, icon: Icon }) => {
const active = theme === value;
return (
<button
key={value}
role="radio"
aria-checked={active}
aria-label={label}
onClick={() => setTheme(value)}
className={cn(
'focus-ring inline-flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-all',
active && 'bg-background text-foreground shadow-sm',
)}
>
<Icon className="h-3.5 w-3.5" strokeWidth={2} />
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils/cn';
const badgeVariants = cva(
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-secondary text-secondary-foreground',
success:
'border-success/20 bg-success/10 text-success dark:bg-success/20',
warning:
'border-warning/20 bg-warning/10 text-warning dark:bg-warning/20',
destructive:
'border-destructive/20 bg-destructive/10 text-destructive dark:bg-destructive/20',
info: 'border-primary/20 bg-primary/10 text-primary',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils/cn';
const buttonVariants = cva(
'focus-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium transition-all disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border',
outline:
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
ghost: 'hover:bg-accent hover:text-accent-foreground',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
link: 'text-primary underline-offset-4 hover:underline rounded-none',
},
size: {
default: 'h-9 px-4 text-sm',
sm: 'h-7 px-3 text-xs',
lg: 'h-11 px-6 text-base',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
},
);
Button.displayName = 'Button';
export { buttonVariants };

View File

@@ -0,0 +1,61 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-2xl border border-border bg-card text-card-foreground',
className,
)}
{...props}
/>
));
Card.displayName = 'Card';
export const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col gap-1 p-6', className)} {...props} />
));
CardHeader.displayName = 'CardHeader';
export const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
));
CardTitle.displayName = 'CardTitle';
export const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
export const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
export const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center justify-between p-6 pt-0', className)}
{...props}
/>
));
CardFooter.displayName = 'CardFooter';

View File

@@ -0,0 +1,34 @@
import type { LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils/cn';
interface EmptyStateProps {
icon?: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
className?: string;
}
export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border px-8 py-16 text-center',
className,
)}
>
{Icon && (
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Icon className="h-5 w-5 text-muted-foreground" strokeWidth={1.75} />
</div>
)}
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold">{title}</h3>
{description && (
<p className="max-w-md text-sm text-muted-foreground">{description}</p>
)}
</div>
{action && <div className="mt-2">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => (
<input
type={type}
ref={ref}
className={cn(
'focus-ring flex h-9 w-full rounded-lg border border-input bg-background px-3 py-1 text-sm transition-colors',
'placeholder:text-muted-foreground',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
));
Input.displayName = 'Input';

View File

@@ -0,0 +1,20 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cn } from '@/lib/utils/cn';
export const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
'text-sm font-medium leading-none text-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;

View File

@@ -0,0 +1,20 @@
import { cn } from '@/lib/utils/cn';
export function Separator({
className,
orientation = 'horizontal',
}: {
className?: string;
orientation?: 'horizontal' | 'vertical';
}) {
return (
<div
role="separator"
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
className,
)}
/>
);
}

View File

@@ -0,0 +1,70 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
export const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
));
Table.displayName = 'Table';
export const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:hairline', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
export const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
));
TableBody.displayName = 'TableBody';
export const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'hairline transition-colors hover:bg-muted/40 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
));
TableRow.displayName = 'TableRow';
export const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-10 px-4 text-left align-middle text-xs font-medium uppercase tracking-wider text-muted-foreground',
className,
)}
{...props}
/>
));
TableHead.displayName = 'TableHead';
export const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn('p-4 align-middle', className)} {...props} />
));
TableCell.displayName = 'TableCell';

44
src/config/env.ts Normal file
View File

@@ -0,0 +1,44 @@
import { z } from 'zod';
/**
* Schéma des variables d'environnement.
* Toute nouvelle variable doit passer par ce schéma → fail-fast au boot.
*/
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
// DB
DATABASE_PROVIDER: z.enum(['sqlite', 'postgresql']).default('sqlite'),
DATABASE_URL: z.string().min(1),
// App
PORT: z.coerce.number().int().positive().default(3000),
APP_URL: z.string().url().default('http://localhost:3000'),
// Discovery
DISCOVERY_DEFAULT_CIDRS: z.string().default('192.168.1.0/24'),
DISCOVERY_DEFAULT_PORTS: z.string().default('22,80,443,8080'),
DISCOVERY_PING_TIMEOUT: z.coerce.number().int().positive().default(1000),
DISCOVERY_PORT_TIMEOUT: z.coerce.number().int().positive().default(800),
DISCOVERY_CONCURRENCY: z.coerce.number().int().positive().default(32),
DISCOVERY_ENABLE_PING: z.coerce.boolean().default(true),
DISCOVERY_ENABLE_PORT_SCAN: z.coerce.boolean().default(true),
DISCOVERY_ENABLE_ARP: z.coerce.boolean().default(true),
DISCOVERY_ENABLE_MDNS: z.coerce.boolean().default(true),
// Logs
LOG_LEVEL: z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
});
export type Env = z.infer<typeof envSchema>;
function loadEnv(): Env {
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Variables d\'environnement invalides :', parsed.error.flatten().fieldErrors);
throw new Error('Configuration invalide');
}
return parsed.data;
}
export const env = loadEnv();

41
src/lib/api/response.ts Normal file
View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { ZodError } from 'zod';
/**
* Helpers API uniformes — à utiliser dans tous les handlers sous `src/app/api/*`.
*/
export function ok<T>(data: T, init?: ResponseInit) {
return NextResponse.json({ data }, init);
}
export function created<T>(data: T) {
return ok(data, { status: 201 });
}
export function noContent() {
return new NextResponse(null, { status: 204 });
}
export function badRequest(message: string, details?: unknown) {
return NextResponse.json({ error: { message, details } }, { status: 400 });
}
export function notFound(message = 'Not found') {
return NextResponse.json({ error: { message } }, { status: 404 });
}
export function serverError(message = 'Internal server error', details?: unknown) {
return NextResponse.json({ error: { message, details } }, { status: 500 });
}
/** Convertit n'importe quelle erreur (y compris Zod) en réponse HTTP propre. */
export function handleError(err: unknown) {
if (err instanceof ZodError) {
return badRequest('Validation failed', err.flatten());
}
if (err instanceof Error) {
return serverError(err.message);
}
return serverError();
}

20
src/lib/db/prisma.ts Normal file
View File

@@ -0,0 +1,20 @@
import { PrismaClient } from '@prisma/client';
/**
* Singleton Prisma client.
* En dev, Next.js hot-reload peut créer plusieurs instances : on cache sur globalThis.
*/
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined;
}
export const prisma =
global.__prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['warn', 'error'],
});
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma;
}

7
src/lib/utils/cn.ts Normal file
View File

@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Merge Tailwind class names en conservant le dernier du même groupe. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

11
src/lib/utils/logger.ts Normal file
View File

@@ -0,0 +1,11 @@
import pino from 'pino';
import { env } from '@/config/env';
export const logger = pino({
level: env.LOG_LEVEL,
base: { app: 'ipam' },
transport:
env.NODE_ENV === 'development'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const createApplicationSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(2000).optional(),
category: z.string().max(50).optional(),
icon: z.string().url().optional(),
url: z.string().url().optional(),
});
export const updateApplicationSchema = createApplicationSchema.partial();
export type CreateApplicationInput = z.infer<typeof createApplicationSchema>;
export type UpdateApplicationInput = z.infer<typeof updateApplicationSchema>;

View File

@@ -0,0 +1,29 @@
import { prisma } from '@/lib/db/prisma';
import {
createApplicationSchema,
updateApplicationSchema,
type CreateApplicationInput,
type UpdateApplicationInput,
} from './applications.schema';
export const applicationsService = {
list: () => prisma.application.findMany({ orderBy: { name: 'asc' } }),
get: (id: string) => prisma.application.findUnique({ where: { id } }),
create: (input: CreateApplicationInput) =>
prisma.application.create({ data: createApplicationSchema.parse(input) }),
update: (id: string, input: UpdateApplicationInput) =>
prisma.application.update({ where: { id }, data: updateApplicationSchema.parse(input) }),
delete: (id: string) => prisma.application.delete({ where: { id } }),
linkToHost: (hostId: string, applicationId: string, notes?: string) =>
prisma.hostApplication.upsert({
where: { hostId_applicationId: { hostId, applicationId } },
update: { notes },
create: { hostId, applicationId, notes },
}),
unlinkFromHost: (hostId: string, applicationId: string) =>
prisma.hostApplication.delete({
where: { hostId_applicationId: { hostId, applicationId } },
}),
};

View File

@@ -0,0 +1,2 @@
export * from './applications.schema';
export * from './applications.service';

View File

@@ -0,0 +1,177 @@
import { prisma } from '@/lib/db/prisma';
import { logger } from '@/lib/utils/logger';
import { getScanner, scannersRegistry } from './registry';
import type { DiscoveryResult, ScannerKind } from './types';
import { hostsRepository } from '../hosts/hosts.repository';
/**
* Orchestrateur de découverte.
* - Enregistre un `Scan` en DB
* - Exécute le scanner demandé (ou l'enchaînement "FULL" ping → port → arp → mdns)
* - Persiste les résultats via les repositories (jamais directement Prisma pour les entités métier)
*/
export const discoveryService = {
listScanners() {
return Object.values(scannersRegistry).map((s) => ({
kind: s.kind,
label: s.label,
enabled: s.enabled,
}));
},
async runSingle(kind: ScannerKind, params: { cidr?: string; ip?: string; ports?: number[] }) {
const scanner = getScanner(kind);
if (!scanner) throw new Error(`Scanner inconnu : ${kind}`);
const scan = await prisma.scan.create({
data: {
type: kind.toUpperCase() as never,
status: 'RUNNING',
target: params.cidr ?? params.ip ?? 'unspecified',
params: JSON.stringify(params),
startedAt: new Date(),
},
});
try {
const results = await scanner.run(params);
await this.persistResults(scan.id, results);
const updated = await prisma.scan.update({
where: { id: scan.id },
data: {
status: 'COMPLETED',
endedAt: new Date(),
hostsFound: results.length,
portsFound: results.reduce((n, r) => n + (r.openPorts?.length ?? 0), 0),
},
});
return updated;
} catch (err) {
logger.error({ err, scanId: scan.id }, 'Scan failed');
return prisma.scan.update({
where: { id: scan.id },
data: {
status: 'FAILED',
endedAt: new Date(),
errorMessage: err instanceof Error ? err.message : String(err),
},
});
}
},
/**
* Scan "complet" : ping sweep → port scan sur les hôtes vivants → ARP + mDNS.
* Logique d'enchaînement, extensible (ex: ajouter SNMP ici plus tard).
*/
async runFull(cidr: string, ports?: number[]) {
const scan = await prisma.scan.create({
data: {
type: 'FULL',
status: 'RUNNING',
target: cidr,
params: JSON.stringify({ cidr, ports }),
startedAt: new Date(),
},
});
const merged = new Map<string, DiscoveryResult>();
const add = (r: DiscoveryResult) => {
const existing = merged.get(r.ipAddress) ?? { ipAddress: r.ipAddress };
merged.set(r.ipAddress, { ...existing, ...r, openPorts: [...(existing.openPorts ?? []), ...(r.openPorts ?? [])], services: [...(existing.services ?? []), ...(r.services ?? [])] });
};
try {
// 1. Ping sweep
if (scannersRegistry.ping.enabled) {
(await scannersRegistry.ping.run({ cidr })).forEach(add);
}
// 2. Port scan sur les hôtes vivants
if (scannersRegistry.port.enabled) {
for (const ip of merged.keys()) {
(await scannersRegistry.port.run({ ip, ports })).forEach(add);
}
}
// 3. ARP
if (scannersRegistry.arp.enabled) {
(await scannersRegistry.arp.run({ cidr })).forEach(add);
}
// 4. mDNS
if (scannersRegistry.mdns.enabled) {
(await scannersRegistry.mdns.run({ cidr })).forEach(add);
}
const results = [...merged.values()];
await this.persistResults(scan.id, results);
return prisma.scan.update({
where: { id: scan.id },
data: {
status: 'COMPLETED',
endedAt: new Date(),
hostsFound: results.length,
portsFound: results.reduce((n, r) => n + (r.openPorts?.length ?? 0), 0),
},
});
} catch (err) {
logger.error({ err, scanId: scan.id }, 'Full scan failed');
return prisma.scan.update({
where: { id: scan.id },
data: {
status: 'FAILED',
endedAt: new Date(),
errorMessage: err instanceof Error ? err.message : String(err),
},
});
}
},
/** Persiste les résultats et crée/met à jour les hôtes/ports correspondants. */
async persistResults(scanId: string, results: DiscoveryResult[]) {
for (const r of results) {
const host = await hostsRepository.upsertByIp(r.ipAddress, {
hostname: r.hostname,
macAddress: r.macAddress,
vendor: r.vendor,
source: 'DISCOVERED',
status: 'UP',
});
await prisma.scanResult.create({
data: {
scanId,
ipAddress: r.ipAddress,
hostId: host.id,
data: JSON.stringify(r),
},
});
for (const p of r.openPorts ?? []) {
await prisma.port.upsert({
where: {
hostId_number_protocol: {
hostId: host.id,
number: p.number,
protocol: p.protocol,
},
},
update: {
state: 'OPEN',
serviceName: p.service,
banner: p.banner,
lastCheckedAt: new Date(),
},
create: {
hostId: host.id,
number: p.number,
protocol: p.protocol,
serviceName: p.service,
banner: p.banner,
state: 'OPEN',
lastCheckedAt: new Date(),
},
});
}
}
},
};

View File

@@ -0,0 +1,3 @@
export * from './types';
export * from './registry';
export * from './discovery.service';

View File

@@ -0,0 +1,26 @@
import type { Scanner, ScannerKind } from './types';
import { pingScanner } from './scanners/ping.scanner';
import { portScanner } from './scanners/port.scanner';
import { arpScanner } from './scanners/arp.scanner';
import { mdnsScanner } from './scanners/mdns.scanner';
/**
* Registre central des scanners.
* Pour ajouter un nouveau type (SNMP, UPnP, SSDP, Netbios...) :
* 1. Créer un fichier sous ./scanners/<kind>.scanner.ts implémentant `Scanner`
* 2. L'ajouter ici → immédiatement disponible dans l'API et l'UI
*/
export const scannersRegistry: Record<ScannerKind, Scanner> = {
ping: pingScanner,
port: portScanner,
arp: arpScanner,
mdns: mdnsScanner,
};
export function getEnabledScanners(): Scanner[] {
return Object.values(scannersRegistry).filter((s) => s.enabled);
}
export function getScanner(kind: ScannerKind): Scanner | undefined {
return scannersRegistry[kind];
}

View File

@@ -0,0 +1,61 @@
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { env } from '@/config/env';
import type { DiscoveryResult, DiscoveryTarget, Scanner } from '../types';
const execAsync = promisify(exec);
/**
* Lit la table ARP locale via `ip neigh` (Linux) ou `arp -a` (fallback).
* Ne scanne pas activement : retourne ce que le kernel a déjà vu.
* Pour forcer la résolution, il faut ping-sweeper d'abord (voir discovery.service).
*/
export const arpScanner: Scanner = {
kind: 'arp',
label: 'Table ARP locale',
enabled: env.DISCOVERY_ENABLE_ARP,
async run(_target: DiscoveryTarget): Promise<DiscoveryResult[]> {
const { stdout } = await execAsync('ip neigh show').catch(() => ({ stdout: '' }));
if (stdout) return parseIpNeigh(stdout);
const { stdout: arpOut } = await execAsync('arp -a').catch(() => ({ stdout: '' }));
return parseArpA(arpOut);
},
};
function parseIpNeigh(output: string): DiscoveryResult[] {
// Exemple : 192.168.1.10 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const ipMatch = line.match(/^(\d{1,3}(?:\.\d{1,3}){3})/);
const macMatch = line.match(/lladdr\s+([0-9a-fA-F:]{17})/);
if (!ipMatch) return null;
return {
ipAddress: ipMatch[1],
macAddress: macMatch?.[1],
} satisfies DiscoveryResult;
})
.filter((r): r is DiscoveryResult => r !== null);
}
function parseArpA(output: string): DiscoveryResult[] {
// Exemple : ? (192.168.1.10) at aa:bb:cc:dd:ee:ff [ether] on eth0
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const ipMatch = line.match(/\((\d{1,3}(?:\.\d{1,3}){3})\)/);
const macMatch = line.match(/([0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5})/);
if (!ipMatch) return null;
return {
ipAddress: ipMatch[1],
macAddress: macMatch?.[1],
} satisfies DiscoveryResult;
})
.filter((r): r is DiscoveryResult => r !== null);
}

View File

@@ -0,0 +1,40 @@
import { Bonjour } from 'bonjour-service';
import { env } from '@/config/env';
import type { DiscoveryResult, DiscoveryTarget, Scanner } from '../types';
/**
* Découverte via mDNS / DNS-SD (_services._dns-sd._udp.local).
* Écoute pendant `timeout` ms et agrège les annonces reçues.
*/
const DEFAULT_TIMEOUT_MS = 4000;
export const mdnsScanner: Scanner = {
kind: 'mdns',
label: 'mDNS / Bonjour',
enabled: env.DISCOVERY_ENABLE_MDNS,
async run(_target: DiscoveryTarget): Promise<DiscoveryResult[]> {
const bonjour = new Bonjour();
const byIp = new Map<string, DiscoveryResult>();
const browser = bonjour.find({});
browser.on('up', (svc) => {
const ips = (svc.addresses ?? []).filter((a) => a && !a.includes(':')); // IPv4
for (const ip of ips) {
const existing = byIp.get(ip) ?? { ipAddress: ip, hostname: svc.host, services: [] };
existing.services!.push({
name: svc.name,
type: `_${svc.type}._${svc.protocol}.local`,
port: svc.port,
});
byIp.set(ip, existing);
}
});
await new Promise((r) => setTimeout(r, DEFAULT_TIMEOUT_MS));
browser.stop();
bonjour.destroy();
return [...byIp.values()];
},
};

View File

@@ -0,0 +1,49 @@
import { Netmask } from 'netmask';
import pingLib from 'ping';
import { env } from '@/config/env';
import type { DiscoveryResult, DiscoveryTarget, ProgressCallback, Scanner } from '../types';
import { runWithConcurrency } from '../utils/concurrency';
/**
* Scanner ICMP (ping sweep).
* Ne requiert PAS de privilèges si la lib fallback utilise l'exécutable système `ping`.
* Dans Docker : bien ajouter `cap_add: [NET_RAW]` (voir docker-compose).
*/
export const pingScanner: Scanner = {
kind: 'ping',
label: 'Ping sweep (ICMP)',
enabled: env.DISCOVERY_ENABLE_PING,
async run(target: DiscoveryTarget, onProgress?: ProgressCallback): Promise<DiscoveryResult[]> {
if (!target.cidr && !target.ip) return [];
const ips: string[] = target.ip
? [target.ip]
: [...expandCidr(target.cidr!)];
let done = 0;
const results: DiscoveryResult[] = [];
await runWithConcurrency(ips, env.DISCOVERY_CONCURRENCY, async (ip) => {
const res = await pingLib.promise.probe(ip, {
timeout: Math.ceil(env.DISCOVERY_PING_TIMEOUT / 1000),
extra: ['-c', '1'],
});
if (res.alive) {
results.push({ ipAddress: ip, hostname: res.host ?? undefined });
}
done += 1;
onProgress?.({ total: ips.length, done, currentTarget: ip });
});
return results;
},
};
function* expandCidr(cidr: string): IterableIterator<string> {
const block = new Netmask(cidr);
// forEach inclut la gateway, exclut broadcast/network → parfait pour un ping sweep
const ips: string[] = [];
block.forEach((ip) => ips.push(ip));
yield* ips;
}

View File

@@ -0,0 +1,75 @@
import net from 'node:net';
import { env } from '@/config/env';
import type { DiscoveryResult, DiscoveryTarget, ProgressCallback, Scanner } from '../types';
import { runWithConcurrency } from '../utils/concurrency';
/**
* Scanner de ports TCP simple (connect scan).
* Pour UDP ou SYN-scan plus avancés, on pourra brancher nmap via child_process
* dans une version ultérieure sans changer l'interface du scanner.
*/
export const portScanner: Scanner = {
kind: 'port',
label: 'Scan de ports TCP',
enabled: env.DISCOVERY_ENABLE_PORT_SCAN,
async run(target: DiscoveryTarget, onProgress?: ProgressCallback): Promise<DiscoveryResult[]> {
if (!target.ip) return [];
const ports =
target.ports?.length
? target.ports
: env.DISCOVERY_DEFAULT_PORTS.split(',').map((p) => Number(p.trim())).filter(Boolean);
let done = 0;
const open: DiscoveryResult['openPorts'] = [];
await runWithConcurrency(ports, env.DISCOVERY_CONCURRENCY, async (port) => {
if (await isPortOpen(target.ip!, port, env.DISCOVERY_PORT_TIMEOUT)) {
open.push({ number: port, protocol: 'TCP', service: guessService(port) });
}
done += 1;
onProgress?.({ total: ports.length, done, currentTarget: `${target.ip}:${port}` });
});
return open.length ? [{ ipAddress: target.ip, openPorts: open }] : [];
},
};
function isPortOpen(host: string, port: number, timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = new net.Socket();
const onDone = (ok: boolean) => {
socket.destroy();
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once('connect', () => onDone(true));
socket.once('timeout', () => onDone(false));
socket.once('error', () => onDone(false));
socket.connect(port, host);
});
}
// Table extensible — à enrichir ou remplacer par une lookup via `nmap-services`.
const COMMON_SERVICES: Record<number, string> = {
22: 'ssh',
53: 'dns',
80: 'http',
81: 'http-alt',
443: 'https',
445: 'smb',
3000: 'nodejs',
3306: 'mysql',
5432: 'postgres',
6379: 'redis',
8080: 'http-alt',
8096: 'jellyfin',
8123: 'homeassistant',
8443: 'https-alt',
32400: 'plex',
};
function guessService(port: number) {
return COMMON_SERVICES[port];
}

View File

@@ -0,0 +1,51 @@
/**
* Types communs à tous les scanners.
* Un scanner est un module qui, pour une cible (CIDR ou IP), renvoie une liste
* de résultats `DiscoveryResult`. Ajouter un nouveau type de scan = implémenter
* `Scanner` et l'enregistrer dans le registry.
*/
export type ScannerKind = 'ping' | 'port' | 'arp' | 'mdns';
export interface DiscoveryTarget {
/** CIDR (192.168.1.0/24), IP unique ou range selon le scanner */
cidr?: string;
ip?: string;
ports?: number[];
}
export interface DiscoveryResult {
ipAddress: string;
hostname?: string;
macAddress?: string;
vendor?: string;
openPorts?: Array<{
number: number;
protocol: 'TCP' | 'UDP';
service?: string;
banner?: string;
}>;
services?: Array<{
name: string;
type: string; // ex: "_http._tcp.local"
port?: number;
}>;
/** Meta libre spécifique à un scanner */
meta?: Record<string, unknown>;
}
export interface ScanProgress {
total: number;
done: number;
currentTarget?: string;
}
export type ProgressCallback = (p: ScanProgress) => void;
export interface Scanner {
readonly kind: ScannerKind;
readonly label: string;
readonly enabled: boolean;
run(target: DiscoveryTarget, onProgress?: ProgressCallback): Promise<DiscoveryResult[]>;
}

View File

@@ -0,0 +1,24 @@
/**
* Exécute `worker` sur chaque item avec une concurrence bornée.
* Pas de dépendance externe (p-limit) pour garder le projet léger.
*/
export async function runWithConcurrency<T>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<void>,
): Promise<void> {
const queue = items.map((item, index) => ({ item, index }));
const workers: Promise<void>[] = [];
const next = async (): Promise<void> => {
const entry = queue.shift();
if (!entry) return;
await worker(entry.item, entry.index);
return next();
};
for (let i = 0; i < Math.min(concurrency, items.length); i++) {
workers.push(next());
}
await Promise.all(workers);
}

View File

@@ -0,0 +1,58 @@
import { prisma } from '@/lib/db/prisma';
import type { CreateHostInput, UpdateHostInput } from './hosts.schema';
/**
* Couche d'accès aux données pour les Hosts.
* Seul endroit où l'on utilise directement Prisma pour ce domaine.
*/
export const hostsRepository = {
list(params: { networkId?: string; status?: string } = {}) {
return prisma.host.findMany({
where: {
networkId: params.networkId,
status: params.status as never,
},
include: {
network: true,
ports: true,
applications: { include: { application: true } },
},
orderBy: { ipAddress: 'asc' },
});
},
get(id: string) {
return prisma.host.findUnique({
where: { id },
include: {
network: true,
ports: true,
applications: { include: { application: true } },
},
});
},
getByIp(ipAddress: string) {
return prisma.host.findUnique({ where: { ipAddress } });
},
create(data: CreateHostInput) {
return prisma.host.create({ data });
},
update(id: string, data: UpdateHostInput) {
return prisma.host.update({ where: { id }, data });
},
delete(id: string) {
return prisma.host.delete({ where: { id } });
},
upsertByIp(ipAddress: string, data: Omit<CreateHostInput, 'ipAddress'>) {
return prisma.host.upsert({
where: { ipAddress },
update: { ...data, lastSeenAt: new Date() },
create: { ipAddress, ...data, lastSeenAt: new Date() },
});
},
};

View File

@@ -0,0 +1,27 @@
import { z } from 'zod';
// Regex basique IPv4 ; on peut étendre à IPv6 avec la lib `ip-address`
const ipv4Regex = /^(25[0-5]|2[0-4]\d|[01]?\d\d?)(\.(25[0-5]|2[0-4]\d|[01]?\d\d?)){3}$/;
export const hostStatusSchema = z.enum(['UP', 'DOWN', 'UNKNOWN']);
export const hostSourceSchema = z.enum(['MANUAL', 'DISCOVERED', 'IMPORTED']);
export const createHostSchema = z.object({
ipAddress: z.string().regex(ipv4Regex, 'IP invalide (IPv4 attendu)'),
hostname: z.string().min(1).max(255).optional(),
macAddress: z
.string()
.regex(/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/, 'MAC invalide')
.optional(),
description: z.string().max(2000).optional(),
networkId: z.string().cuid().optional(),
status: hostStatusSchema.optional(),
source: hostSourceSchema.optional(),
vendor: z.string().optional(),
osGuess: z.string().optional(),
});
export const updateHostSchema = createHostSchema.partial();
export type CreateHostInput = z.infer<typeof createHostSchema>;
export type UpdateHostInput = z.infer<typeof updateHostSchema>;

View File

@@ -0,0 +1,25 @@
import { hostsRepository } from './hosts.repository';
import { createHostSchema, updateHostSchema } from './hosts.schema';
import type { CreateHostInput, UpdateHostInput } from './hosts.schema';
/**
* Logique métier des Hosts.
* - Ne doit JAMAIS être appelée depuis un Server Component sans être typée/validée.
* - Les routes API (`src/app/api/hosts/*`) l'utilisent pour rester fines.
*/
export const hostsService = {
list: hostsRepository.list,
get: hostsRepository.get,
async create(input: CreateHostInput) {
const data = createHostSchema.parse(input);
return hostsRepository.create(data);
},
async update(id: string, input: UpdateHostInput) {
const data = updateHostSchema.parse(input);
return hostsRepository.update(id, data);
},
delete: hostsRepository.delete,
};

View File

@@ -0,0 +1,2 @@
export * from './hosts.schema';
export * from './hosts.service';

View File

@@ -0,0 +1,2 @@
export * from './networks.schema';
export * from './networks.service';

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
export const createNetworkSchema = z.object({
name: z.string().min(1).max(100),
cidr: z.string().regex(cidrRegex, 'CIDR invalide (ex: 192.168.1.0/24)'),
description: z.string().max(2000).optional(),
vlanId: z.number().int().min(0).max(4094).optional(),
gateway: z.string().optional(),
dnsServers: z.string().optional(),
color: z
.string()
.regex(/^#[0-9A-Fa-f]{6}$/)
.optional(),
});
export const updateNetworkSchema = createNetworkSchema.partial();
export type CreateNetworkInput = z.infer<typeof createNetworkSchema>;
export type UpdateNetworkInput = z.infer<typeof updateNetworkSchema>;

View File

@@ -0,0 +1,18 @@
import { prisma } from '@/lib/db/prisma';
import {
createNetworkSchema,
updateNetworkSchema,
type CreateNetworkInput,
type UpdateNetworkInput,
} from './networks.schema';
export const networksService = {
list: () => prisma.network.findMany({ include: { hosts: true } }),
get: (id: string) =>
prisma.network.findUnique({ where: { id }, include: { hosts: true } }),
create: (input: CreateNetworkInput) =>
prisma.network.create({ data: createNetworkSchema.parse(input) }),
update: (id: string, input: UpdateNetworkInput) =>
prisma.network.update({ where: { id }, data: updateNetworkSchema.parse(input) }),
delete: (id: string) => prisma.network.delete({ where: { id } }),
};

View File

@@ -0,0 +1,2 @@
export * from './ports.schema';
export * from './ports.service';

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
export const portProtocolSchema = z.enum(['TCP', 'UDP']);
export const portStateSchema = z.enum(['OPEN', 'CLOSED', 'FILTERED', 'UNKNOWN']);
export const createPortSchema = z.object({
hostId: z.string().cuid(),
number: z.number().int().min(1).max(65535),
protocol: portProtocolSchema.default('TCP'),
serviceName: z.string().optional(),
banner: z.string().optional(),
state: portStateSchema.default('OPEN'),
applicationId: z.string().cuid().optional(),
});
export const updatePortSchema = createPortSchema.partial().omit({ hostId: true });
export type CreatePortInput = z.infer<typeof createPortSchema>;
export type UpdatePortInput = z.infer<typeof updatePortSchema>;

View File

@@ -0,0 +1,24 @@
import { prisma } from '@/lib/db/prisma';
import {
createPortSchema,
updatePortSchema,
type CreatePortInput,
type UpdatePortInput,
} from './ports.schema';
export const portsService = {
listByHost: (hostId: string) =>
prisma.port.findMany({
where: { hostId },
include: { application: true },
orderBy: [{ protocol: 'asc' }, { number: 'asc' }],
}),
create: (input: CreatePortInput) =>
prisma.port.create({ data: createPortSchema.parse(input) }),
update: (id: string, input: UpdatePortInput) =>
prisma.port.update({ where: { id }, data: updatePortSchema.parse(input) }),
delete: (id: string) => prisma.port.delete({ where: { id } }),
};

View File

@@ -0,0 +1 @@
export * from './scans.service';

View File

@@ -0,0 +1,25 @@
import { prisma } from '@/lib/db/prisma';
/**
* Historique des scans.
* La création/MAJ des scans est pilotée par `discoveryService` ;
* ce service n'expose que la lecture pour les pages d'historique.
*/
export const scansService = {
list: () =>
prisma.scan.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
}),
get: (id: string) =>
prisma.scan.findUnique({
where: { id },
include: {
results: {
include: { host: true },
take: 500,
},
},
}),
};

123
src/styles/globals.css Normal file
View File

@@ -0,0 +1,123 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ---------------------------------------------------------------
* Palette inspirée Apple — neutre, sobre
* Light : fond blanc cassé, textes graphite, accent bleu système
* Dark : fond anthracite, textes beige clair, accent bleu vif
* ------------------------------------------------------------- */
@layer base {
:root {
--background: 0 0% 99%;
--foreground: 0 0% 9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 9%;
--primary: 211 100% 50%; /* #007AFF — bleu Apple */
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 96%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 40%;
--accent: 0 0% 96%;
--accent-foreground: 0 0% 9%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--success: 142 71% 45%;
--success-foreground: 0 0% 100%;
--warning: 36 100% 50%;
--warning-foreground: 0 0% 9%;
--border: 0 0% 91%;
--input: 0 0% 91%;
--ring: 211 100% 50%;
--radius: 0.75rem;
}
.dark {
--background: 0 0% 7%;
--foreground: 0 0% 96%;
--card: 0 0% 9%;
--card-foreground: 0 0% 96%;
--popover: 0 0% 9%;
--popover-foreground: 0 0% 96%;
--primary: 211 100% 57%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 14%;
--secondary-foreground: 0 0% 96%;
--muted: 0 0% 14%;
--muted-foreground: 0 0% 60%;
--accent: 0 0% 14%;
--accent-foreground: 0 0% 96%;
--destructive: 0 72% 55%;
--destructive-foreground: 0 0% 100%;
--success: 142 65% 50%;
--success-foreground: 0 0% 100%;
--warning: 36 95% 55%;
--warning-foreground: 0 0% 9%;
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 211 100% 57%;
}
}
@layer base {
* {
@apply border-border;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings:
'cv02', 'cv03', 'cv04', 'cv11';
}
body {
@apply bg-background text-foreground;
font-family:
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF Pro Display',
'Inter', 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
letter-spacing: -0.01em;
}
h1, h2, h3, h4, h5, h6 {
letter-spacing: -0.022em;
font-weight: 600;
}
}
@layer utilities {
/* Apple-style frosted glass */
.glass {
@apply bg-background/70 backdrop-blur-xl backdrop-saturate-150;
}
/* Subtle hairline divider */
.hairline {
@apply border-b border-border/60;
}
/* Smooth focus ring */
.focus-ring {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
}

93
tailwind.config.ts Normal file
View File

@@ -0,0 +1,93 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: [
'./src/app/**/*.{ts,tsx}',
'./src/components/**/*.{ts,tsx}',
'./src/modules/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: '1.5rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
success: {
DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 4px)',
sm: 'calc(var(--radius) - 8px)',
},
fontSize: {
// Échelle typographique plus resserrée (style Apple)
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.8125rem', { lineHeight: '1.125rem' }],
base: ['0.9375rem', { lineHeight: '1.375rem' }],
lg: ['1.0625rem', { lineHeight: '1.5rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem', letterSpacing: '-0.022em' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem', letterSpacing: '-0.025em' }],
'4xl': ['2.5rem', { lineHeight: '3rem', letterSpacing: '-0.028em' }],
'5xl': ['3.25rem', { lineHeight: '3.5rem', letterSpacing: '-0.03em' }],
},
keyframes: {
'fade-in': {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
'fade-in': 'fade-in 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;

40
tsconfig.json Normal file
View File

@@ -0,0 +1,40 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@/app/*": ["./src/app/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/modules/*": ["./src/modules/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/types/*": ["./src/types/*"],
"@/config/*": ["./src/config/*"]
},
"baseUrl": "."
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules", ".next", "dist"]
}