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