first commit
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user