Add new pages for ML training and IP whitelisting

Adds new frontend pages for ML training and IP whitelisting, along with backend API endpoints to trigger ML actions and manage the whitelist.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 7a657272-55ba-4a79-9a2e-f1ed9bc7a528
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Event-Id: d6b17610-31b4-4ed0-a52a-66659e36d756
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/449cf7c4-c97a-45ae-8234-e5c5b8d6a84f/7a657272-55ba-4a79-9a2e-f1ed9bc7a528/Aqah4U9
This commit is contained in:
marco370 2025-11-21 09:00:50 +00:00
parent 84320a3c6f
commit 7171c3d607
5 changed files with 723 additions and 1 deletions

View File

@ -14,6 +14,10 @@ run = ["npm", "run", "start"]
localPort = 5000
externalPort = 80
[[ports]]
localPort = 43079
externalPort = 3001
[[ports]]
localPort = 43803
externalPort = 3000

View File

@ -4,15 +4,18 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SidebarProvider, Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarTrigger } from "@/components/ui/sidebar";
import { LayoutDashboard, AlertTriangle, Server, Shield, Menu } from "lucide-react";
import { LayoutDashboard, AlertTriangle, Server, Shield, Brain, Menu } from "lucide-react";
import Dashboard from "@/pages/Dashboard";
import Detections from "@/pages/Detections";
import Routers from "@/pages/Routers";
import Whitelist from "@/pages/Whitelist";
import Training from "@/pages/Training";
import NotFound from "@/pages/not-found";
const menuItems = [
{ title: "Dashboard", url: "/", icon: LayoutDashboard },
{ title: "Rilevamenti", url: "/detections", icon: AlertTriangle },
{ title: "Training ML", url: "/training", icon: Brain },
{ title: "Router", url: "/routers", icon: Server },
{ title: "Whitelist", url: "/whitelist", icon: Shield },
];
@ -48,7 +51,9 @@ function Router() {
<Switch>
<Route path="/" component={Dashboard} />
<Route path="/detections" component={Detections} />
<Route path="/training" component={Training} />
<Route path="/routers" component={Routers} />
<Route path="/whitelist" component={Whitelist} />
<Route component={NotFound} />
</Switch>
);

View File

@ -0,0 +1,421 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Brain, Play, Search, CheckCircle2, XCircle, Clock, TrendingUp } from "lucide-react";
import { format } from "date-fns";
import type { TrainingHistory } from "@shared/schema";
import { useToast } from "@/hooks/use-toast";
import { useState } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
interface MLStatsResponse {
logs?: { total: number; last_hour: number };
detections?: { total: number; blocked: number };
routers?: { active: number };
latest_training?: any;
}
export default function TrainingPage() {
const { toast } = useToast();
const [isTrainDialogOpen, setIsTrainDialogOpen] = useState(false);
const [isDetectDialogOpen, setIsDetectDialogOpen] = useState(false);
const [trainRecords, setTrainRecords] = useState("100000");
const [trainHours, setTrainHours] = useState("24");
const [detectRecords, setDetectRecords] = useState("50000");
const [detectHours, setDetectHours] = useState("1");
const [detectThreshold, setDetectThreshold] = useState("75");
const [detectAutoBlock, setDetectAutoBlock] = useState(true);
const { data: history, isLoading } = useQuery<TrainingHistory[]>({
queryKey: ["/api/training-history"],
refetchInterval: 10000,
});
const { data: mlStats } = useQuery<MLStatsResponse>({
queryKey: ["/api/ml/stats"],
refetchInterval: 10000,
});
const trainMutation = useMutation({
mutationFn: async (params: { max_records: number; hours_back: number }) => {
return await apiRequest("POST", "/api/ml/train", params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/training-history"] });
queryClient.invalidateQueries({ queryKey: ["/api/ml/stats"] });
toast({
title: "Training avviato",
description: "Il modello ML è in addestramento. Controlla lo storico tra qualche minuto.",
});
setIsTrainDialogOpen(false);
},
onError: () => {
toast({
title: "Errore",
description: "Impossibile avviare il training",
variant: "destructive",
});
},
});
const detectMutation = useMutation({
mutationFn: async (params: { max_records: number; hours_back: number; risk_threshold: number; auto_block: boolean }) => {
return await apiRequest("POST", "/api/ml/detect", params);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/detections"] });
queryClient.invalidateQueries({ queryKey: ["/api/stats"] });
toast({
title: "Detection avviata",
description: "Analisi anomalie in corso. Controlla i rilevamenti tra qualche secondo.",
});
setIsDetectDialogOpen(false);
},
onError: () => {
toast({
title: "Errore",
description: "Impossibile avviare la detection",
variant: "destructive",
});
},
});
const handleTrain = () => {
const records = parseInt(trainRecords);
const hours = parseInt(trainHours);
if (isNaN(records) || records <= 0) {
toast({
title: "Errore",
description: "Inserisci un numero valido di record",
variant: "destructive",
});
return;
}
trainMutation.mutate({ max_records: records, hours_back: hours });
};
const handleDetect = () => {
const records = parseInt(detectRecords);
const hours = parseInt(detectHours);
const threshold = parseInt(detectThreshold);
if (isNaN(records) || records <= 0 || isNaN(threshold) || threshold < 0 || threshold > 100) {
toast({
title: "Errore",
description: "Inserisci valori validi",
variant: "destructive",
});
return;
}
detectMutation.mutate({
max_records: records,
hours_back: hours,
risk_threshold: threshold,
auto_block: detectAutoBlock,
});
};
return (
<div className="flex flex-col gap-6 p-6" data-testid="page-training">
<div>
<h1 className="text-3xl font-semibold" data-testid="text-page-title">Machine Learning</h1>
<p className="text-muted-foreground" data-testid="text-page-subtitle">
Training e detection del modello Isolation Forest
</p>
</div>
{/* ML Stats */}
{mlStats && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card data-testid="card-ml-logs">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Log Totali</CardTitle>
<Brain className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold" data-testid="text-ml-logs-total">
{mlStats.logs?.total?.toLocaleString() || 0}
</div>
<p className="text-xs text-muted-foreground mt-1">
Ultima ora: {mlStats.logs?.last_hour?.toLocaleString() || 0}
</p>
</CardContent>
</Card>
<Card data-testid="card-ml-detections">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Detection Totali</CardTitle>
<Search className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold" data-testid="text-ml-detections-total">
{mlStats.detections?.total || 0}
</div>
<p className="text-xs text-muted-foreground mt-1">
Bloccati: {mlStats.detections?.blocked || 0}
</p>
</CardContent>
</Card>
<Card data-testid="card-ml-routers">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Router Attivi</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold" data-testid="text-ml-routers-active">
{mlStats.routers?.active || 0}
</div>
</CardContent>
</Card>
</div>
)}
{/* Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card data-testid="card-train-action">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Brain className="h-5 w-5" />
Addestramento Modello
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Addestra il modello Isolation Forest analizzando i log recenti per rilevare pattern di traffico normale.
</p>
<Dialog open={isTrainDialogOpen} onOpenChange={setIsTrainDialogOpen}>
<DialogTrigger asChild>
<Button className="w-full" data-testid="button-start-training">
<Play className="h-4 w-4 mr-2" />
Avvia Training
</Button>
</DialogTrigger>
<DialogContent data-testid="dialog-training">
<DialogHeader>
<DialogTitle>Avvia Training ML</DialogTitle>
<DialogDescription>
Configura i parametri per l'addestramento del modello
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="train-records">Numero Record</Label>
<Input
id="train-records"
type="number"
value={trainRecords}
onChange={(e) => setTrainRecords(e.target.value)}
data-testid="input-train-records"
/>
<p className="text-xs text-muted-foreground">Consigliato: 100000</p>
</div>
<div className="space-y-2">
<Label htmlFor="train-hours">Ore Precedenti</Label>
<Input
id="train-hours"
type="number"
value={trainHours}
onChange={(e) => setTrainHours(e.target.value)}
data-testid="input-train-hours"
/>
<p className="text-xs text-muted-foreground">Consigliato: 24</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsTrainDialogOpen(false)} data-testid="button-cancel-training">
Annulla
</Button>
<Button onClick={handleTrain} disabled={trainMutation.isPending} data-testid="button-confirm-training">
{trainMutation.isPending ? "Avvio..." : "Avvia Training"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
<Card data-testid="card-detect-action">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
Rilevamento Anomalie
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Analizza i log recenti per rilevare anomalie e IP sospetti. Opzionalmente blocca automaticamente gli IP critici.
</p>
<Dialog open={isDetectDialogOpen} onOpenChange={setIsDetectDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" className="w-full" data-testid="button-start-detection">
<Search className="h-4 w-4 mr-2" />
Avvia Detection
</Button>
</DialogTrigger>
<DialogContent data-testid="dialog-detection">
<DialogHeader>
<DialogTitle>Avvia Detection Anomalie</DialogTitle>
<DialogDescription>
Configura i parametri per il rilevamento anomalie
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="detect-records">Numero Record</Label>
<Input
id="detect-records"
type="number"
value={detectRecords}
onChange={(e) => setDetectRecords(e.target.value)}
data-testid="input-detect-records"
/>
<p className="text-xs text-muted-foreground">Consigliato: 50000</p>
</div>
<div className="space-y-2">
<Label htmlFor="detect-hours">Ore Precedenti</Label>
<Input
id="detect-hours"
type="number"
value={detectHours}
onChange={(e) => setDetectHours(e.target.value)}
data-testid="input-detect-hours"
/>
<p className="text-xs text-muted-foreground">Consigliato: 1</p>
</div>
<div className="space-y-2">
<Label htmlFor="detect-threshold">Soglia Rischio (%)</Label>
<Input
id="detect-threshold"
type="number"
min="0"
max="100"
value={detectThreshold}
onChange={(e) => setDetectThreshold(e.target.value)}
data-testid="input-detect-threshold"
/>
<p className="text-xs text-muted-foreground">Consigliato: 75</p>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="auto-block"
checked={detectAutoBlock}
onChange={(e) => setDetectAutoBlock(e.target.checked)}
className="rounded border-gray-300"
data-testid="checkbox-auto-block"
/>
<Label htmlFor="auto-block">Blocco automatico IP critici (80)</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDetectDialogOpen(false)} data-testid="button-cancel-detection">
Annulla
</Button>
<Button onClick={handleDetect} disabled={detectMutation.isPending} data-testid="button-confirm-detection">
{detectMutation.isPending ? "Avvio..." : "Avvia Detection"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
</div>
{/* Training History */}
<Card data-testid="card-training-history">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Storico Training ({history?.length || 0})
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground" data-testid="text-loading">
Caricamento...
</div>
) : history && history.length > 0 ? (
<div className="space-y-3">
{history.map((item) => (
<div
key={item.id}
className="p-4 rounded-lg border hover-elevate"
data-testid={`training-item-${item.id}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium" data-testid={`text-version-${item.id}`}>
Versione {item.modelVersion}
</p>
{item.status === "success" ? (
<Badge variant="outline" className="bg-green-50" data-testid={`badge-status-${item.id}`}>
<CheckCircle2 className="h-3 w-3 mr-1" />
Successo
</Badge>
) : (
<Badge variant="destructive" data-testid={`badge-status-${item.id}`}>
<XCircle className="h-3 w-3 mr-1" />
Fallito
</Badge>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-muted-foreground">
<div>
<span className="font-medium">Record:</span> {item.recordsProcessed.toLocaleString()}
</div>
<div>
<span className="font-medium">Feature:</span> {item.featuresCount}
</div>
{item.accuracy && (
<div>
<span className="font-medium">Accuracy:</span> {item.accuracy}%
</div>
)}
{item.trainingDuration && (
<div>
<span className="font-medium">Durata:</span> {item.trainingDuration}s
</div>
)}
</div>
<p className="text-xs text-muted-foreground" data-testid={`text-date-${item.id}`}>
{format(new Date(item.trainedAt), "dd/MM/yyyy HH:mm:ss")}
</p>
{item.notes && (
<p className="text-sm text-muted-foreground" data-testid={`text-notes-${item.id}`}>
{item.notes}
</p>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground" data-testid="text-empty">
<Brain className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="font-medium">Nessun training eseguito</p>
<p className="text-sm mt-2">Avvia il primo training per addestrare il modello ML</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,234 @@
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Shield, Plus, Trash2, CheckCircle2, XCircle } from "lucide-react";
import { format } from "date-fns";
import { useState } from "react";
import type { Whitelist } from "@shared/schema";
import { useToast } from "@/hooks/use-toast";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from "@/components/ui/dialog";
export default function WhitelistPage() {
const { toast } = useToast();
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [newIp, setNewIp] = useState("");
const [newComment, setNewComment] = useState("");
const [newReason, setNewReason] = useState("");
const { data: whitelist, isLoading } = useQuery<Whitelist[]>({
queryKey: ["/api/whitelist"],
});
const addMutation = useMutation({
mutationFn: async (data: { ipAddress: string; comment?: string; reason?: string }) => {
return await apiRequest("POST", "/api/whitelist", data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/whitelist"] });
toast({
title: "IP aggiunto",
description: "L'indirizzo IP è stato aggiunto alla whitelist",
});
setIsAddDialogOpen(false);
setNewIp("");
setNewComment("");
setNewReason("");
},
onError: () => {
toast({
title: "Errore",
description: "Impossibile aggiungere l'IP alla whitelist",
variant: "destructive",
});
},
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await apiRequest("DELETE", `/api/whitelist/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/whitelist"] });
toast({
title: "IP rimosso",
description: "L'indirizzo IP è stato rimosso dalla whitelist",
});
},
onError: () => {
toast({
title: "Errore",
description: "Impossibile rimuovere l'IP dalla whitelist",
variant: "destructive",
});
},
});
const handleAdd = () => {
if (!newIp.trim()) {
toast({
title: "Errore",
description: "Inserisci un indirizzo IP valido",
variant: "destructive",
});
return;
}
addMutation.mutate({
ipAddress: newIp.trim(),
comment: newComment.trim() || undefined,
reason: newReason.trim() || undefined,
});
};
return (
<div className="flex flex-col gap-6 p-6" data-testid="page-whitelist">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-semibold" data-testid="text-page-title">Whitelist IP</h1>
<p className="text-muted-foreground" data-testid="text-page-subtitle">
Gestisci gli indirizzi IP fidati che non verranno bloccati
</p>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button data-testid="button-add-whitelist">
<Plus className="h-4 w-4 mr-2" />
Aggiungi IP
</Button>
</DialogTrigger>
<DialogContent data-testid="dialog-add-whitelist">
<DialogHeader>
<DialogTitle>Aggiungi IP alla Whitelist</DialogTitle>
<DialogDescription>
Inserisci l'indirizzo IP che vuoi proteggere dal blocco automatico
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="ip">Indirizzo IP *</Label>
<Input
id="ip"
placeholder="192.168.1.100"
value={newIp}
onChange={(e) => setNewIp(e.target.value)}
data-testid="input-ip"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reason">Motivo</Label>
<Input
id="reason"
placeholder="Es: Server backup"
value={newReason}
onChange={(e) => setNewReason(e.target.value)}
data-testid="input-reason"
/>
</div>
<div className="space-y-2">
<Label htmlFor="comment">Note</Label>
<Textarea
id="comment"
placeholder="Note aggiuntive..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
data-testid="input-comment"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)} data-testid="button-cancel">
Annulla
</Button>
<Button onClick={handleAdd} disabled={addMutation.isPending} data-testid="button-confirm-add">
{addMutation.isPending ? "Aggiunta..." : "Aggiungi"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<Card data-testid="card-whitelist">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
IP Protetti ({whitelist?.length || 0})
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground" data-testid="text-loading">
Caricamento...
</div>
) : whitelist && whitelist.length > 0 ? (
<div className="space-y-3">
{whitelist.map((item) => (
<div
key={item.id}
className="p-4 rounded-lg border hover-elevate"
data-testid={`whitelist-item-${item.id}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<p className="font-mono font-medium" data-testid={`text-ip-${item.id}`}>
{item.ipAddress}
</p>
{item.active ? (
<CheckCircle2 className="h-4 w-4 text-green-500" data-testid={`icon-active-${item.id}`} />
) : (
<XCircle className="h-4 w-4 text-muted-foreground" data-testid={`icon-inactive-${item.id}`} />
)}
</div>
{item.reason && (
<p className="text-sm text-muted-foreground" data-testid={`text-reason-${item.id}`}>
<span className="font-medium">Motivo:</span> {item.reason}
</p>
)}
{item.comment && (
<p className="text-sm text-muted-foreground" data-testid={`text-comment-${item.id}`}>
{item.comment}
</p>
)}
<p className="text-xs text-muted-foreground" data-testid={`text-created-${item.id}`}>
Aggiunto il {format(new Date(item.createdAt), "dd/MM/yyyy HH:mm")}
{item.createdBy && ` da ${item.createdBy}`}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
data-testid={`button-delete-${item.id}`}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground" data-testid="text-empty">
<Shield className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="font-medium">Nessun IP in whitelist</p>
<p className="text-sm mt-2">Aggiungi indirizzi IP fidati per proteggerli dal blocco automatico</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -166,6 +166,64 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ML Actions - Trigger training/detection on Python backend
app.post("/api/ml/train", async (req, res) => {
try {
const { max_records = 100000, hours_back = 24 } = req.body;
const response = await fetch("http://localhost:8000/train", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ max_records, hours_back }),
});
if (!response.ok) {
throw new Error("Python backend training failed");
}
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).json({ error: "Failed to trigger training" });
}
});
app.post("/api/ml/detect", async (req, res) => {
try {
const { max_records = 50000, hours_back = 1, risk_threshold = 75, auto_block = false } = req.body;
const response = await fetch("http://localhost:8000/detect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ max_records, hours_back, risk_threshold, auto_block }),
});
if (!response.ok) {
throw new Error("Python backend detection failed");
}
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).json({ error: "Failed to trigger detection" });
}
});
app.get("/api/ml/stats", async (req, res) => {
try {
const response = await fetch("http://localhost:8000/stats");
if (!response.ok) {
throw new Error("Python backend stats failed");
}
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).json({ error: "Failed to fetch ML stats" });
}
});
const httpServer = createServer(app);
return httpServer;
}