first commit

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

View File

@@ -0,0 +1,91 @@
import { AppWindow, ExternalLink, Plus } from 'lucide-react';
import { Header } from '@/components/layout/header';
import { PageContainer } from '@/components/layout/page-container';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import { prisma } from '@/lib/db/prisma';
async function getApplications() {
try {
return await prisma.application.findMany({
include: { _count: { select: { hosts: true, ports: true } } },
orderBy: { name: 'asc' },
});
} catch {
return [];
}
}
export default async function ApplicationsPage() {
const apps = await getApplications();
return (
<>
<Header
title="Applications"
description={`${apps.length} services`}
actions={
<Button size="sm">
<Plus className="h-4 w-4" />
Nouvelle application
</Button>
}
/>
<PageContainer>
{apps.length === 0 ? (
<EmptyState
icon={AppWindow}
title="Aucune application"
description="Ajoutez vos services homelab (Jellyfin, Home Assistant, Nextcloud…) et liez-les à leurs hôtes."
action={<Button size="sm">Ajouter une application</Button>}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{apps.map((a) => (
<Card key={a.id} className="flex flex-col p-6 transition-all hover:shadow-sm">
<div className="flex items-start justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-muted">
{a.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={a.icon} alt="" className="h-5 w-5 rounded" />
) : (
<AppWindow className="h-4 w-4 text-muted-foreground" strokeWidth={2} />
)}
</div>
{a.category && <Badge variant="outline">{a.category}</Badge>}
</div>
<div className="mt-5 flex flex-col gap-1">
<h3 className="text-base font-semibold">{a.name}</h3>
{a.description && (
<p className="line-clamp-2 text-sm text-muted-foreground">
{a.description}
</p>
)}
</div>
<div className="mt-auto flex items-center justify-between border-t border-border pt-4 text-sm">
<span className="text-muted-foreground">
{a._count.hosts} hôte{a._count.hosts > 1 ? 's' : ''} ·{' '}
{a._count.ports} port{a._count.ports > 1 ? 's' : ''}
</span>
{a.url && (
<a
href={a.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-primary hover:underline"
>
Ouvrir
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</Card>
))}
</div>
)}
</PageContainer>
</>
);
}

View File

@@ -0,0 +1,123 @@
import { Plus, Server } from 'lucide-react';
import { Header } from '@/components/layout/header';
import { PageContainer } from '@/components/layout/page-container';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { prisma } from '@/lib/db/prisma';
async function getHosts() {
try {
return await prisma.host.findMany({
include: {
network: true,
ports: { where: { state: 'OPEN' } },
applications: { include: { application: true } },
},
orderBy: { ipAddress: 'asc' },
});
} catch {
return [];
}
}
const statusVariant = {
UP: 'success',
DOWN: 'destructive',
UNKNOWN: 'default',
} as const;
export default async function HostsPage() {
const hosts = await getHosts();
return (
<>
<Header
title="Hôtes"
description={`${hosts.length} machines recensées`}
actions={
<Button size="sm">
<Plus className="h-4 w-4" />
Ajouter un hôte
</Button>
}
/>
<PageContainer>
{hosts.length === 0 ? (
<EmptyState
icon={Server}
title="Aucun hôte"
description="Ajoutez un hôte manuellement ou lancez une découverte pour peupler votre inventaire automatiquement."
action={<Button size="sm">Lancer une découverte</Button>}
/>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Adresse IP</TableHead>
<TableHead>Nom</TableHead>
<TableHead>Réseau</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Ports</TableHead>
<TableHead>Applications</TableHead>
<TableHead className="text-right">Dernière vue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hosts.map((h) => (
<TableRow key={h.id}>
<TableCell className="font-mono text-sm">{h.ipAddress}</TableCell>
<TableCell className="font-medium">{h.hostname ?? '—'}</TableCell>
<TableCell className="text-muted-foreground">
{h.network?.name ?? '—'}
</TableCell>
<TableCell>
<Badge variant={statusVariant[h.status]}>{h.status}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{h.ports.length > 0 ? (
<span className="tabular-nums">{h.ports.length}</span>
) : (
'—'
)}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{h.applications.slice(0, 3).map((ha) => (
<Badge key={ha.applicationId} variant="outline">
{ha.application.name}
</Badge>
))}
{h.applications.length > 3 && (
<Badge variant="outline">+{h.applications.length - 3}</Badge>
)}
{h.applications.length === 0 && (
<span className="text-muted-foreground"></span>
)}
</div>
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">
{h.lastSeenAt
? new Date(h.lastSeenAt).toLocaleString('fr-FR')
: '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</PageContainer>
</>
);
}

View File

@@ -0,0 +1,5 @@
// Route group conservé pour organiser les pages du dashboard ;
// le sidebar est monté dans le layout racine pour éviter les conflits de routing.
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,81 @@
import { Network as NetworkIcon, Plus } from 'lucide-react';
import { Header } from '@/components/layout/header';
import { PageContainer } from '@/components/layout/page-container';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import { prisma } from '@/lib/db/prisma';
async function getNetworks() {
try {
return await prisma.network.findMany({
include: { _count: { select: { hosts: true } } },
orderBy: { name: 'asc' },
});
} catch {
return [];
}
}
export default async function NetworksPage() {
const networks = await getNetworks();
return (
<>
<Header
title="Réseaux"
description={`${networks.length} sous-réseaux`}
actions={
<Button size="sm">
<Plus className="h-4 w-4" />
Nouveau réseau
</Button>
}
/>
<PageContainer>
{networks.length === 0 ? (
<EmptyState
icon={NetworkIcon}
title="Aucun réseau"
description="Déclarez votre premier sous-réseau (ex: 192.168.1.0/24) pour organiser vos hôtes."
action={<Button size="sm">Créer un réseau</Button>}
/>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{networks.map((n) => (
<Card key={n.id} className="p-6 transition-all hover:shadow-sm">
<div className="flex items-start justify-between">
<div
className="flex h-10 w-10 items-center justify-center rounded-xl"
style={{ backgroundColor: `${n.color ?? '#3b82f6'}20` }}
>
<NetworkIcon
className="h-4 w-4"
style={{ color: n.color ?? '#3b82f6' }}
strokeWidth={2}
/>
</div>
{n.vlanId !== null && n.vlanId !== undefined && (
<Badge variant="outline">VLAN {n.vlanId}</Badge>
)}
</div>
<div className="mt-5 flex flex-col gap-1">
<h3 className="text-base font-semibold">{n.name}</h3>
<p className="font-mono text-sm text-muted-foreground">{n.cidr}</p>
{n.description && (
<p className="mt-1 text-sm text-muted-foreground">{n.description}</p>
)}
</div>
<div className="mt-5 flex items-center justify-between border-t border-border pt-4 text-sm">
<span className="text-muted-foreground">Hôtes</span>
<span className="font-medium tabular-nums">{n._count.hosts}</span>
</div>
</Card>
))}
</div>
)}
</PageContainer>
</>
);
}

View File

@@ -0,0 +1,106 @@
import { Radar } from 'lucide-react';
import { Header } from '@/components/layout/header';
import { PageContainer } from '@/components/layout/page-container';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { DiscoveryLauncher } from '@/components/scans/discovery-launcher';
import { prisma } from '@/lib/db/prisma';
async function getScans() {
try {
return await prisma.scan.findMany({
orderBy: { createdAt: 'desc' },
take: 50,
});
} catch {
return [];
}
}
const statusVariant = {
COMPLETED: 'success',
RUNNING: 'info',
PENDING: 'default',
FAILED: 'destructive',
CANCELLED: 'warning',
} as const;
function formatDuration(start: Date | null, end: Date | null) {
if (!start || !end) return '—';
const diff = Math.max(0, end.getTime() - start.getTime());
if (diff < 1000) return `${diff} ms`;
if (diff < 60_000) return `${(diff / 1000).toFixed(1)} s`;
return `${Math.floor(diff / 60_000)} min ${Math.floor((diff % 60_000) / 1000)} s`;
}
export default async function ScansPage() {
const scans = await getScans();
return (
<>
<Header title="Découverte" description="Scans récents du réseau" />
<PageContainer>
<DiscoveryLauncher />
<section>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">
Historique
</h2>
{scans.length === 0 ? (
<EmptyState
icon={Radar}
title="Aucun scan effectué"
description="Lancez votre premier scan ci-dessus pour découvrir automatiquement les hôtes et services de votre réseau."
/>
) : (
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Cible</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Hôtes</TableHead>
<TableHead>Ports</TableHead>
<TableHead>Durée</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scans.map((s) => (
<TableRow key={s.id}>
<TableCell>
<Badge variant="outline">{s.type}</Badge>
</TableCell>
<TableCell className="font-mono text-sm">{s.target}</TableCell>
<TableCell>
<Badge variant={statusVariant[s.status]}>{s.status}</Badge>
</TableCell>
<TableCell className="tabular-nums">{s.hostsFound}</TableCell>
<TableCell className="tabular-nums">{s.portsFound}</TableCell>
<TableCell className="text-muted-foreground tabular-nums">
{formatDuration(s.startedAt, s.endedAt)}
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">
{new Date(s.createdAt).toLocaleString('fr-FR')}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
)}
</section>
</PageContainer>
</>
);
}

View File

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

View File

@@ -0,0 +1,32 @@
import type { NextRequest } from 'next/server';
import { z } from 'zod';
import { discoveryService } from '@/modules/discovery';
import { handleError, ok } from '@/lib/api/response';
const bodySchema = z.object({
kind: z.enum(['ping', 'port', 'arp', 'mdns', 'full']),
cidr: z.string().optional(),
ip: z.string().optional(),
ports: z.array(z.number().int().min(1).max(65535)).optional(),
});
/** POST /api/discovery — lance un scan */
export async function POST(req: NextRequest) {
try {
const { kind, cidr, ip, ports } = bodySchema.parse(await req.json());
const scan =
kind === 'full'
? await discoveryService.runFull(cidr!, ports)
: await discoveryService.runSingle(kind, { cidr, ip, ports });
return ok(scan);
} catch (err) {
return handleError(err);
}
}
/** GET /api/discovery — liste les scanners disponibles */
export async function GET() {
return ok(discoveryService.listScanners());
}

View File

@@ -0,0 +1,32 @@
import type { NextRequest } from 'next/server';
import { hostsService } from '@/modules/hosts';
import { handleError, noContent, notFound, ok } from '@/lib/api/response';
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
try {
const host = await hostsService.get(params.id);
if (!host) return notFound('Host not found');
return ok(host);
} catch (err) {
return handleError(err);
}
}
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
try {
const body = await req.json();
const host = await hostsService.update(params.id, body);
return ok(host);
} catch (err) {
return handleError(err);
}
}
export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
try {
await hostsService.delete(params.id);
return noContent();
} catch (err) {
return handleError(err);
}
}

View File

@@ -0,0 +1,26 @@
import type { NextRequest } from 'next/server';
import { hostsService } from '@/modules/hosts';
import { handleError, ok, created } from '@/lib/api/response';
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const hosts = await hostsService.list({
networkId: searchParams.get('networkId') ?? undefined,
status: searchParams.get('status') ?? undefined,
});
return ok(hosts);
} catch (err) {
return handleError(err);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const host = await hostsService.create(body);
return created(host);
} catch (err) {
return handleError(err);
}
}

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

@@ -0,0 +1,47 @@
import type { Metadata } from 'next';
import { ThemeProvider } from '@/components/theme/theme-provider';
import { Sidebar } from '@/components/layout/sidebar';
import '@/styles/globals.css';
export const metadata: Metadata = {
title: 'IPAM Homelab',
description: 'Gestion des IP, ports et applications de votre homelab',
};
/**
* Script inline exécuté AVANT l'hydratation React pour appliquer le bon thème
* et éviter le flash (FOUC) côté client.
*/
const themeInitScript = `
(function() {
try {
var stored = localStorage.getItem('ipam-theme');
var theme = stored || 'system';
var resolved = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
var root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(resolved);
root.style.colorScheme = resolved;
} catch (_) {}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="min-h-screen bg-background font-sans antialiased">
<ThemeProvider>
<div className="flex min-h-screen">
<Sidebar />
<div className="flex-1">{children}</div>
</div>
</ThemeProvider>
</body>
</html>
);
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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