first commit
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
52
.env.example
Normal 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
10
.eslintrc.json
Normal 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
54
.gitignore
vendored
Normal 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
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
||||||
147
ARCHITECTURE.md
Normal file
147
ARCHITECTURE.md
Normal 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
91
README.md
Normal 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
44
docker/Dockerfile
Normal 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
61
docker/docker-compose.yml
Normal 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
28
next.config.mjs
Normal 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
74
package.json
Normal 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
6
postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
213
prisma/schema.prisma
Normal file
213
prisma/schema.prisma
Normal 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
62
prisma/seed.ts
Normal 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
37
scripts/run-discovery.ts
Normal 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);
|
||||||
|
});
|
||||||
91
src/app/(dashboard)/applications/page.tsx
Normal file
91
src/app/(dashboard)/applications/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/app/(dashboard)/hosts/page.tsx
Normal file
123
src/app/(dashboard)/hosts/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/(dashboard)/layout.tsx
Normal file
5
src/app/(dashboard)/layout.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
81
src/app/(dashboard)/networks/page.tsx
Normal file
81
src/app/(dashboard)/networks/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/app/(dashboard)/scans/page.tsx
Normal file
106
src/app/(dashboard)/scans/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/app/(dashboard)/settings/page.tsx
Normal file
70
src/app/(dashboard)/settings/page.tsx
Normal 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 l’application" />
|
||||||
|
<PageContainer>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Apparence</CardTitle>
|
||||||
|
<CardDescription>Choisissez le thème de l’interface</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 l’inventaire 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/app/api/discovery/route.ts
Normal file
32
src/app/api/discovery/route.ts
Normal 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());
|
||||||
|
}
|
||||||
32
src/app/api/hosts/[id]/route.ts
Normal file
32
src/app/api/hosts/[id]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/api/hosts/route.ts
Normal file
26
src/app/api/hosts/route.ts
Normal 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
47
src/app/layout.tsx
Normal 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
181
src/app/page.tsx
Normal 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 d’ensemble"
|
||||||
|
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 l’onglet 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/layout/header.tsx
Normal file
42
src/components/layout/header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/components/layout/page-container.tsx
Normal file
20
src/components/layout/page-container.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/layout/sidebar.tsx
Normal file
70
src/components/layout/sidebar.tsx
Normal 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 d’ensemble', 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/components/scans/discovery-launcher.tsx
Normal file
104
src/components/scans/discovery-launcher.tsx
Normal 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 d’hô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>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/theme/theme-provider.tsx
Normal file
67
src/components/theme/theme-provider.tsx
Normal 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;
|
||||||
|
}
|
||||||
45
src/components/theme/theme-toggle.tsx
Normal file
45
src/components/theme/theme-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/ui/badge.tsx
Normal file
33
src/components/ui/badge.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
55
src/components/ui/button.tsx
Normal file
55
src/components/ui/button.tsx
Normal 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 };
|
||||||
61
src/components/ui/card.tsx
Normal file
61
src/components/ui/card.tsx
Normal 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';
|
||||||
34
src/components/ui/empty-state.tsx
Normal file
34
src/components/ui/empty-state.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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';
|
||||||
20
src/components/ui/label.tsx
Normal file
20
src/components/ui/label.tsx
Normal 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;
|
||||||
20
src/components/ui/separator.tsx
Normal file
20
src/components/ui/separator.tsx
Normal 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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/ui/table.tsx
Normal file
70
src/components/ui/table.tsx
Normal 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
44
src/config/env.ts
Normal 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
41
src/lib/api/response.ts
Normal 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
20
src/lib/db/prisma.ts
Normal 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
7
src/lib/utils/cn.ts
Normal 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
11
src/lib/utils/logger.ts
Normal 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,
|
||||||
|
});
|
||||||
13
src/modules/applications/applications.schema.ts
Normal file
13
src/modules/applications/applications.schema.ts
Normal 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>;
|
||||||
29
src/modules/applications/applications.service.ts
Normal file
29
src/modules/applications/applications.service.ts
Normal 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 } },
|
||||||
|
}),
|
||||||
|
};
|
||||||
2
src/modules/applications/index.ts
Normal file
2
src/modules/applications/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './applications.schema';
|
||||||
|
export * from './applications.service';
|
||||||
177
src/modules/discovery/discovery.service.ts
Normal file
177
src/modules/discovery/discovery.service.ts
Normal 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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
3
src/modules/discovery/index.ts
Normal file
3
src/modules/discovery/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './registry';
|
||||||
|
export * from './discovery.service';
|
||||||
26
src/modules/discovery/registry.ts
Normal file
26
src/modules/discovery/registry.ts
Normal 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];
|
||||||
|
}
|
||||||
61
src/modules/discovery/scanners/arp.scanner.ts
Normal file
61
src/modules/discovery/scanners/arp.scanner.ts
Normal 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);
|
||||||
|
}
|
||||||
40
src/modules/discovery/scanners/mdns.scanner.ts
Normal file
40
src/modules/discovery/scanners/mdns.scanner.ts
Normal 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()];
|
||||||
|
},
|
||||||
|
};
|
||||||
49
src/modules/discovery/scanners/ping.scanner.ts
Normal file
49
src/modules/discovery/scanners/ping.scanner.ts
Normal 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;
|
||||||
|
}
|
||||||
75
src/modules/discovery/scanners/port.scanner.ts
Normal file
75
src/modules/discovery/scanners/port.scanner.ts
Normal 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];
|
||||||
|
}
|
||||||
51
src/modules/discovery/types.ts
Normal file
51
src/modules/discovery/types.ts
Normal 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[]>;
|
||||||
|
}
|
||||||
24
src/modules/discovery/utils/concurrency.ts
Normal file
24
src/modules/discovery/utils/concurrency.ts
Normal 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);
|
||||||
|
}
|
||||||
58
src/modules/hosts/hosts.repository.ts
Normal file
58
src/modules/hosts/hosts.repository.ts
Normal 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() },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
27
src/modules/hosts/hosts.schema.ts
Normal file
27
src/modules/hosts/hosts.schema.ts
Normal 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>;
|
||||||
25
src/modules/hosts/hosts.service.ts
Normal file
25
src/modules/hosts/hosts.service.ts
Normal 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,
|
||||||
|
};
|
||||||
2
src/modules/hosts/index.ts
Normal file
2
src/modules/hosts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './hosts.schema';
|
||||||
|
export * from './hosts.service';
|
||||||
2
src/modules/networks/index.ts
Normal file
2
src/modules/networks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './networks.schema';
|
||||||
|
export * from './networks.service';
|
||||||
20
src/modules/networks/networks.schema.ts
Normal file
20
src/modules/networks/networks.schema.ts
Normal 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>;
|
||||||
18
src/modules/networks/networks.service.ts
Normal file
18
src/modules/networks/networks.service.ts
Normal 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 } }),
|
||||||
|
};
|
||||||
2
src/modules/ports/index.ts
Normal file
2
src/modules/ports/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ports.schema';
|
||||||
|
export * from './ports.service';
|
||||||
18
src/modules/ports/ports.schema.ts
Normal file
18
src/modules/ports/ports.schema.ts
Normal 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>;
|
||||||
24
src/modules/ports/ports.service.ts
Normal file
24
src/modules/ports/ports.service.ts
Normal 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 } }),
|
||||||
|
};
|
||||||
1
src/modules/scans/index.ts
Normal file
1
src/modules/scans/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './scans.service';
|
||||||
25
src/modules/scans/scans.service.ts
Normal file
25
src/modules/scans/scans.service.ts
Normal 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
123
src/styles/globals.css
Normal 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
93
tailwind.config.ts
Normal 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
40
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user