Initial commit: Werkzeuge-Sammlung
Enthält: - rdp_client.py: RDP Client mit GUI und Monitor-Auswahl - rdp.sh: Bash-basierter RDP Client - teamleader_test/: Network Scanner Fullstack-App - teamleader_test2/: Network Mapper CLI Subdirectories mit eigenem Repo wurden ausgeschlossen. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
27
teamleader_test/frontend/src/App.tsx
Normal file
27
teamleader_test/frontend/src/App.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import NetworkPage from './pages/NetworkPage';
|
||||
import HostsPage from './pages/HostsPage';
|
||||
import ScansPage from './pages/ScansPage';
|
||||
import ServiceHostsPage from './pages/ServiceHostsPage';
|
||||
import HostDetailPage from './pages/HostDetailPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/network" element={<NetworkPage />} />
|
||||
<Route path="/hosts" element={<HostsPage />} />
|
||||
<Route path="/hosts/:hostId" element={<HostDetailPage />} />
|
||||
<Route path="/services/:serviceName" element={<ServiceHostsPage />} />
|
||||
<Route path="/scans" element={<ScansPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
141
teamleader_test/frontend/src/components/HostDetails.tsx
Normal file
141
teamleader_test/frontend/src/components/HostDetails.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { X, Server, Activity, Clock, MapPin } from 'lucide-react';
|
||||
import type { HostWithServices } from '../types/api';
|
||||
import { formatDate, getStatusColor } from '../utils/helpers';
|
||||
|
||||
interface HostDetailsProps {
|
||||
host: HostWithServices;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function HostDetails({ host, onClose }: HostDetailsProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-slate-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Server className="w-6 h-6 text-primary-500" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-100">
|
||||
{host.hostname || host.ip_address}
|
||||
</h2>
|
||||
{host.hostname && (
|
||||
<p className="text-sm text-slate-400">{host.ip_address}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{/* Status and Info */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-slate-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Activity className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm text-slate-400">Status</span>
|
||||
</div>
|
||||
<span className={`text-lg font-medium ${getStatusColor(host.status)}`}>
|
||||
{host.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<MapPin className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm text-slate-400">MAC Address</span>
|
||||
</div>
|
||||
<span className="text-sm text-slate-100 font-mono">
|
||||
{host.mac_address || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm text-slate-400">First Seen</span>
|
||||
</div>
|
||||
<span className="text-sm text-slate-100">
|
||||
{formatDate(host.first_seen)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-700/50 rounded-lg p-4">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm text-slate-400">Last Seen</span>
|
||||
</div>
|
||||
<span className="text-sm text-slate-100">
|
||||
{formatDate(host.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-100 mb-4">
|
||||
Services ({host.services.length})
|
||||
</h3>
|
||||
|
||||
{host.services.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
No services detected
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{host.services.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className="bg-slate-700/30 rounded-lg p-4 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-lg font-mono font-medium text-primary-400">
|
||||
{service.port}/{service.protocol}
|
||||
</span>
|
||||
{service.service_name && (
|
||||
<span className="px-2 py-1 bg-slate-600 text-slate-100 text-xs font-medium rounded">
|
||||
{service.service_name}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
service.state === 'open'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{service.state}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{service.service_version && (
|
||||
<div className="mt-2 text-sm text-slate-300">
|
||||
Version: {service.service_version}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.banner && (
|
||||
<div className="mt-2 p-2 bg-slate-900/50 rounded text-xs font-mono text-slate-400">
|
||||
{service.banner}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
teamleader_test/frontend/src/components/HostDetailsPanel.tsx
Normal file
222
teamleader_test/frontend/src/components/HostDetailsPanel.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, Globe, Network, Cpu, Server, AlertCircle, Loader } from 'lucide-react';
|
||||
import type { HostWithServices } from '../types/api';
|
||||
import { hostApi } from '../services/api';
|
||||
|
||||
interface HostDetailsPanelProps {
|
||||
hostId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function HostDetailsPanel({ hostId, onClose }: HostDetailsPanelProps) {
|
||||
const [host, setHost] = useState<HostWithServices | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHostDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await hostApi.getHost(parseInt(hostId));
|
||||
setHost(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load host details');
|
||||
console.error('Failed to fetch host details:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (hostId) {
|
||||
fetchHostDetails();
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed right-0 top-0 h-full w-96 bg-slate-800 border-l border-slate-700 shadow-lg flex items-center justify-center">
|
||||
<Loader className="w-8 h-8 text-primary-400 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !host) {
|
||||
return (
|
||||
<div className="fixed right-0 top-0 h-full w-96 bg-slate-800 border-l border-slate-700 shadow-lg p-6 flex flex-col">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-100">Host Details</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-start space-x-3 p-4 bg-red-900/20 border border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-300">{error || 'Failed to load host details'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusColor = host.status === 'online'
|
||||
? 'bg-green-900/20 border-green-800 text-green-400'
|
||||
: 'bg-red-900/20 border-red-800 text-red-400';
|
||||
|
||||
return (
|
||||
<div className="fixed right-0 top-0 h-full w-96 bg-slate-800 border-l border-slate-700 shadow-lg overflow-y-auto">
|
||||
<div className="sticky top-0 bg-slate-800/95 backdrop-blur-sm z-10 border-b border-slate-700 p-6 flex justify-between items-start">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-100">{host.hostname || 'Unknown Host'}</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">{host.ip_address}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Status</label>
|
||||
<div className={`px-3 py-2 rounded-lg border ${statusColor} text-sm font-medium`}>
|
||||
{host.status.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-300 flex items-center space-x-2">
|
||||
<Globe className="w-4 h-4" />
|
||||
<span>Basic Information</span>
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">IP Address:</span>
|
||||
<span className="text-slate-200 font-mono">{host.ip_address}</span>
|
||||
</div>
|
||||
{host.mac_address && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">MAC Address:</span>
|
||||
<span className="text-slate-200 font-mono text-xs">{host.mac_address}</span>
|
||||
</div>
|
||||
)}
|
||||
{host.vendor && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Vendor:</span>
|
||||
<span className="text-slate-200">{host.vendor}</span>
|
||||
</div>
|
||||
)}
|
||||
{host.device_type && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Device Type:</span>
|
||||
<span className="text-slate-200 capitalize">{host.device_type}</span>
|
||||
</div>
|
||||
)}
|
||||
{host.os_guess && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">OS Guess:</span>
|
||||
<span className="text-slate-200">{host.os_guess}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-300 flex items-center space-x-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
<span>Timeline</span>
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">First Seen:</span>
|
||||
<span className="text-slate-200">
|
||||
{new Date(host.first_seen).toLocaleDateString()} {new Date(host.first_seen).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Last Seen:</span>
|
||||
<span className="text-slate-200">
|
||||
{new Date(host.last_seen).toLocaleDateString()} {new Date(host.last_seen).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services/Ports */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-slate-300 flex items-center space-x-2">
|
||||
<Server className="w-4 h-4" />
|
||||
<span>Services ({host.services?.length || 0})</span>
|
||||
</h3>
|
||||
{host.services && host.services.length > 0 ? (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{host.services.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className="p-3 bg-slate-700/50 border border-slate-600 rounded-lg text-sm hover:bg-slate-700/70 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Network className="w-4 h-4 text-slate-400" />
|
||||
<span className="font-medium text-slate-100">
|
||||
Port {service.port}/{service.protocol.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
service.state === 'open'
|
||||
? 'bg-green-900/30 text-green-400 border border-green-800'
|
||||
: service.state === 'closed'
|
||||
? 'bg-red-900/30 text-red-400 border border-red-800'
|
||||
: 'bg-yellow-900/30 text-yellow-400 border border-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{service.state}
|
||||
</span>
|
||||
</div>
|
||||
{service.service_name && (
|
||||
<div className="text-slate-300">
|
||||
<span className="text-slate-400">Service: </span>
|
||||
<span>{service.service_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{service.service_version && (
|
||||
<div className="text-slate-300 text-xs">
|
||||
<span className="text-slate-400">Version: </span>
|
||||
<span>{service.service_version}</span>
|
||||
</div>
|
||||
)}
|
||||
{service.banner && (
|
||||
<div className="text-slate-300 text-xs mt-2 p-2 bg-slate-800/50 rounded font-mono break-all">
|
||||
<span className="text-slate-400">Banner: </span>
|
||||
<span>{service.banner}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-400 p-3 bg-slate-700/30 rounded-lg">
|
||||
No services detected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{host.notes && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-slate-300">Notes</h3>
|
||||
<p className="text-sm text-slate-300 p-3 bg-slate-700/30 rounded-lg">{host.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
teamleader_test/frontend/src/components/HostNode.tsx
Normal file
79
teamleader_test/frontend/src/components/HostNode.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from 'reactflow';
|
||||
import { Server, Monitor, Smartphone, Globe, HelpCircle } from 'lucide-react';
|
||||
|
||||
interface HostNodeData {
|
||||
ip: string;
|
||||
hostname: string | null;
|
||||
type: string;
|
||||
status: 'up' | 'down';
|
||||
service_count: number;
|
||||
color: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function HostNode({ data }: { data: HostNodeData }) {
|
||||
const getIcon = () => {
|
||||
switch (data.type) {
|
||||
case 'gateway':
|
||||
return <Globe className="w-5 h-5" />;
|
||||
case 'server':
|
||||
return <Server className="w-5 h-5" />;
|
||||
case 'workstation':
|
||||
return <Monitor className="w-5 h-5" />;
|
||||
case 'device':
|
||||
return <Smartphone className="w-5 h-5" />;
|
||||
default:
|
||||
return <HelpCircle className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={data.onClick}
|
||||
className="relative cursor-pointer group"
|
||||
>
|
||||
<Handle type="target" position={Position.Top} />
|
||||
|
||||
<div
|
||||
className="px-4 py-3 rounded-lg shadow-lg border-2 transition-all group-hover:shadow-xl group-hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: '#1e293b',
|
||||
borderColor: data.status === 'up' ? data.color : '#64748b',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${data.color}20`, color: data.color }}
|
||||
>
|
||||
{getIcon()}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-slate-100 truncate">
|
||||
{data.hostname || data.ip}
|
||||
</div>
|
||||
{data.hostname && (
|
||||
<div className="text-xs text-slate-400 truncate">{data.ip}</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
data.status === 'up' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">
|
||||
{data.service_count} service{data.service_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(HostNode);
|
||||
66
teamleader_test/frontend/src/components/Layout.tsx
Normal file
66
teamleader_test/frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Network, Activity, List, Home } from 'lucide-react';
|
||||
import { cn } from '../utils/helpers';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: Home },
|
||||
{ path: '/network', label: 'Network Map', icon: Network },
|
||||
{ path: '/hosts', label: 'Hosts', icon: List },
|
||||
{ path: '/scans', label: 'Scans', icon: Activity },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-900 text-slate-100">
|
||||
{/* Header */}
|
||||
<header className="bg-slate-800 border-b border-slate-700">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Network className="w-8 h-8 text-primary-500" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Network Scanner</h1>
|
||||
<p className="text-sm text-slate-400">Network Discovery & Visualization</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="bg-slate-800 border-b border-slate-700">
|
||||
<div className="px-6">
|
||||
<div className="flex space-x-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path;
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={cn(
|
||||
'flex items-center space-x-2 px-4 py-3 text-sm font-medium transition-colors',
|
||||
'border-b-2',
|
||||
isActive
|
||||
? 'border-primary-500 text-primary-500'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-600'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
teamleader_test/frontend/src/components/NetworkMap.tsx
Normal file
157
teamleader_test/frontend/src/components/NetworkMap.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
MarkerType,
|
||||
Panel,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
import { Download, RefreshCw, Network } from 'lucide-react';
|
||||
import type { Topology } from '../types/api';
|
||||
import { getNodeTypeColor } from '../utils/helpers';
|
||||
import HostDetailsPanel from './HostDetailsPanel';
|
||||
|
||||
interface NetworkMapProps {
|
||||
topology: Topology;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export default function NetworkMap({ topology, onRefresh }: NetworkMapProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
const [selectedHostId, setSelectedHostId] = useState<string | null>(null);
|
||||
|
||||
// Convert topology to React Flow nodes and edges
|
||||
useEffect(() => {
|
||||
if (!topology || !topology.nodes) return;
|
||||
|
||||
// Create nodes with circular layout
|
||||
const newNodes: Node[] = topology.nodes.map((node, index) => {
|
||||
const angle = (index / Math.max(topology.nodes.length, 1)) * 2 * Math.PI;
|
||||
const radius = Math.max(300, topology.nodes.length * 30);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
data: {
|
||||
label: node.hostname || node.ip,
|
||||
ip: node.ip,
|
||||
type: node.type,
|
||||
status: node.status,
|
||||
services: node.service_count,
|
||||
},
|
||||
position: {
|
||||
x: Math.cos(angle) * radius + 400,
|
||||
y: Math.sin(angle) * radius + 300,
|
||||
},
|
||||
style: {
|
||||
background: getNodeTypeColor(node.type),
|
||||
border: node.status === 'online' || node.status === 'up' ? '2px solid #10b981' : '2px solid #6b7280',
|
||||
borderRadius: '8px',
|
||||
padding: '10px',
|
||||
minWidth: '100px',
|
||||
textAlign: 'center' as const,
|
||||
cursor: 'pointer',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Create edges
|
||||
const newEdges: Edge[] = (topology.edges || []).map((edge, index) => ({
|
||||
id: `e-${edge.source}-${edge.target}-${index}`,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
animated: edge.confidence > 0.7,
|
||||
style: {
|
||||
stroke: `rgba(100, 116, 139, ${edge.confidence})`,
|
||||
strokeWidth: 2,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: `rgba(100, 116, 139, ${edge.confidence})`,
|
||||
},
|
||||
}));
|
||||
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
}, [topology, setNodes, setEdges]);
|
||||
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
setSelectedHostId(nodeId);
|
||||
};
|
||||
|
||||
if (!topology || !topology.nodes || topology.nodes.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-slate-400">
|
||||
<div className="text-center">
|
||||
<Network className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">No network topology data available</p>
|
||||
<p className="text-sm mt-2">Run a scan to discover your network</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-slate-800 rounded-lg overflow-hidden relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={(_, node) => handleNodeClick(node.id)}
|
||||
fitView
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Background color="#334155" gap={16} />
|
||||
<Controls className="bg-slate-700 border-slate-600" />
|
||||
|
||||
<Panel position="top-right" className="space-x-2">
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-slate-100 rounded-lg transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => alert('Export functionality coming soon')}
|
||||
className="px-3 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors flex items-center space-x-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</Panel>
|
||||
|
||||
<Panel position="bottom-left" className="bg-slate-700/90 backdrop-blur-sm rounded-lg p-4">
|
||||
<div className="text-sm space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Nodes:</span>
|
||||
<span className="text-slate-100 font-medium">{topology.statistics.total_nodes}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Connections:</span>
|
||||
<span className="text-slate-100 font-medium">{topology.statistics.total_edges}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Isolated:</span>
|
||||
<span className="text-slate-100 font-medium">{topology.statistics.isolated_nodes}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
||||
{selectedHostId && (
|
||||
<HostDetailsPanel
|
||||
hostId={selectedHostId}
|
||||
onClose={() => setSelectedHostId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
teamleader_test/frontend/src/components/ScanForm.tsx
Normal file
140
teamleader_test/frontend/src/components/ScanForm.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react';
|
||||
import { Play, X } from 'lucide-react';
|
||||
import type { ScanRequest } from '../types/api';
|
||||
import { scanApi } from '../services/api';
|
||||
|
||||
interface ScanFormProps {
|
||||
onScanStarted?: (scanId: number) => void;
|
||||
}
|
||||
|
||||
export default function ScanForm({ onScanStarted }: ScanFormProps) {
|
||||
const [formData, setFormData] = useState<ScanRequest>({
|
||||
network_range: '192.168.1.0/24',
|
||||
scan_type: 'quick',
|
||||
include_service_detection: true,
|
||||
use_nmap: true,
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const scan = await scanApi.startScan(formData);
|
||||
onScanStarted?.(scan.scan_id);
|
||||
// Reset form
|
||||
setFormData({
|
||||
network_range: '',
|
||||
scan_type: 'quick',
|
||||
include_service_detection: true,
|
||||
use_nmap: true,
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to start scan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Target Network
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.network_range}
|
||||
onChange={(e) => setFormData({ ...formData, network_range: e.target.value })}
|
||||
placeholder="192.168.1.0/24"
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Enter network in CIDR notation (e.g., 192.168.1.0/24)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Scan Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.scan_type}
|
||||
onChange={(e) => setFormData({ ...formData, scan_type: e.target.value as ScanRequest['scan_type'] })}
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-slate-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="quick">Quick (Common Ports)</option>
|
||||
<option value="standard">Standard (Top 1000)</option>
|
||||
<option value="deep">Deep (All Ports)</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Options
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center space-x-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.include_service_detection}
|
||||
onChange={(e) => setFormData({ ...formData, include_service_detection: e.target.checked })}
|
||||
className="rounded bg-slate-700 border-slate-600"
|
||||
/>
|
||||
<span>Service Detection</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.scan_type === 'custom' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
Port Range
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.port_range || ''}
|
||||
onChange={(e) => setFormData({ ...formData, port_range: e.target.value })}
|
||||
placeholder="1-1000,8080,8443"
|
||||
className="w-full px-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Example: 1-1000,8080,8443
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center space-x-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<X className="w-4 h-4 text-red-500" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full flex items-center justify-center space-x-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 disabled:bg-slate-600 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Starting Scan...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
<span>Start Scan</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
58
teamleader_test/frontend/src/hooks/useHosts.ts
Normal file
58
teamleader_test/frontend/src/hooks/useHosts.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Host, HostWithServices } from '../types/api';
|
||||
import { hostApi } from '../services/api';
|
||||
|
||||
export function useHosts() {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await hostApi.listHosts();
|
||||
setHosts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch hosts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
}, []);
|
||||
|
||||
return { hosts, loading, error, refetch: fetchHosts };
|
||||
}
|
||||
|
||||
export function useHost(hostId: number | null) {
|
||||
const [host, setHost] = useState<HostWithServices | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
setHost(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchHost = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await hostApi.getHost(hostId);
|
||||
setHost(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch host');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchHost();
|
||||
}, [hostId]);
|
||||
|
||||
return { host, loading, error };
|
||||
}
|
||||
61
teamleader_test/frontend/src/hooks/useScans.ts
Normal file
61
teamleader_test/frontend/src/hooks/useScans.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Scan } from '../types/api';
|
||||
import { scanApi } from '../services/api';
|
||||
|
||||
export function useScans() {
|
||||
const [scans, setScans] = useState<Scan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchScans = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await scanApi.listScans();
|
||||
setScans(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch scans');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchScans();
|
||||
}, []);
|
||||
|
||||
return { scans, loading, error, refetch: fetchScans };
|
||||
}
|
||||
|
||||
export function useScan(scanId: number | null) {
|
||||
const [scan, setScan] = useState<Scan | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scanId) {
|
||||
setScan(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchScan = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await scanApi.getScanStatus(scanId);
|
||||
setScan(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch scan');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScan();
|
||||
const interval = setInterval(fetchScan, 2000); // Poll every 2 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [scanId]);
|
||||
|
||||
return { scan, loading, error };
|
||||
}
|
||||
28
teamleader_test/frontend/src/hooks/useTopology.ts
Normal file
28
teamleader_test/frontend/src/hooks/useTopology.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Topology } from '../types/api';
|
||||
import { topologyApi } from '../services/api';
|
||||
|
||||
export function useTopology() {
|
||||
const [topology, setTopology] = useState<Topology | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchTopology = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await topologyApi.getTopology();
|
||||
setTopology(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch topology');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTopology();
|
||||
}, []);
|
||||
|
||||
return { topology, loading, error, refetch: fetchTopology };
|
||||
}
|
||||
32
teamleader_test/frontend/src/hooks/useWebSocket.ts
Normal file
32
teamleader_test/frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { WebSocketClient, type WSMessageHandler } from '../services/websocket';
|
||||
|
||||
export function useWebSocket(handlers: WSMessageHandler) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [client] = useState(() => new WebSocketClient({
|
||||
...handlers,
|
||||
onConnect: () => {
|
||||
setIsConnected(true);
|
||||
handlers.onConnect?.();
|
||||
},
|
||||
onDisconnect: () => {
|
||||
setIsConnected(false);
|
||||
handlers.onDisconnect?.();
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
client.connect();
|
||||
|
||||
return () => {
|
||||
client.disconnect();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
client.disconnect();
|
||||
client.connect();
|
||||
}, [client]);
|
||||
|
||||
return { isConnected, reconnect };
|
||||
}
|
||||
86
teamleader_test/frontend/src/index.css
Normal file
86
teamleader_test/frontend/src/index.css
Normal file
@@ -0,0 +1,86 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #0f172a;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* React Flow Custom Styles */
|
||||
.react-flow__node {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.react-flow__handle {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.react-flow__node:hover .react-flow__handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Pulse animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
10
teamleader_test/frontend/src/main.tsx
Normal file
10
teamleader_test/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
237
teamleader_test/frontend/src/pages/Dashboard.tsx
Normal file
237
teamleader_test/frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Activity, Server, Zap, TrendingUp, X } from 'lucide-react';
|
||||
import ScanForm from '../components/ScanForm';
|
||||
import { hostApi, scanApi } from '../services/api';
|
||||
import { useScans } from '../hooks/useScans';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import type { HostStatistics } from '../types/api';
|
||||
import { getScanStatusColor } from '../utils/helpers';
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { scans, refetch: refetchScans } = useScans();
|
||||
const [statistics, setStatistics] = useState<HostStatistics | null>(null);
|
||||
const [scanProgress, setScanProgress] = useState<Record<number, { progress: number; message: string }>>({});
|
||||
|
||||
useWebSocket({
|
||||
onScanProgress: (data) => {
|
||||
console.log('Scan progress:', data);
|
||||
setScanProgress(prev => ({
|
||||
...prev,
|
||||
[data.scan_id]: {
|
||||
progress: data.progress,
|
||||
message: data.current_host || 'Scanning...'
|
||||
}
|
||||
}));
|
||||
},
|
||||
onScanComplete: () => {
|
||||
refetchScans();
|
||||
fetchStatistics();
|
||||
// Clear progress for completed scans after a short delay
|
||||
setTimeout(() => {
|
||||
setScanProgress({});
|
||||
}, 2000);
|
||||
},
|
||||
onHostDiscovered: (data) => {
|
||||
console.log('Host discovered:', data);
|
||||
fetchStatistics();
|
||||
},
|
||||
});
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
try {
|
||||
const stats = await hostApi.getHostStatistics();
|
||||
setStatistics(stats);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch statistics:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatistics();
|
||||
}, []);
|
||||
|
||||
const recentScans = scans.slice(0, 5);
|
||||
|
||||
const handleCancelScan = async (scanId: number) => {
|
||||
try {
|
||||
await scanApi.cancelScan(scanId);
|
||||
refetchScans();
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel scan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-100">Dashboard</h1>
|
||||
<p className="text-slate-400 mt-1">Network scanning overview and control</p>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">Total Hosts</p>
|
||||
<p className="text-3xl font-bold text-slate-100 mt-2">
|
||||
{statistics?.total_hosts || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-primary-500/20 rounded-lg">
|
||||
<Server className="w-8 h-8 text-primary-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">Online Hosts</p>
|
||||
<p className="text-3xl font-bold text-green-500 mt-2">
|
||||
{statistics?.online_hosts || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-500/20 rounded-lg">
|
||||
<Activity className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">Total Services</p>
|
||||
<p className="text-3xl font-bold text-slate-100 mt-2">
|
||||
{statistics?.total_services || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-500/20 rounded-lg">
|
||||
<Zap className="w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-400">Total Scans</p>
|
||||
<p className="text-3xl font-bold text-slate-100 mt-2">
|
||||
{scans.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-500/20 rounded-lg">
|
||||
<TrendingUp className="w-8 h-8 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Scan Form */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-4">Start New Scan</h2>
|
||||
<ScanForm onScanStarted={() => { refetchScans(); fetchStatistics(); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Scans */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-4">Recent Scans</h2>
|
||||
|
||||
{recentScans.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400">
|
||||
No scans yet. Start your first scan to discover your network.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentScans.map((scan) => {
|
||||
const progress = scanProgress[scan.id];
|
||||
const isRunning = scan.status === 'running';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={scan.id}
|
||||
className="bg-slate-700/30 rounded-lg p-4 hover:bg-slate-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="font-medium text-slate-100">{scan.network_range}</span>
|
||||
<span className="px-2 py-1 bg-slate-600 text-slate-100 text-xs font-medium rounded">
|
||||
{scan.scan_type}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${getScanStatusColor(scan.status)}`}>
|
||||
{scan.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress information */}
|
||||
{isRunning && progress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>{progress.message}</span>
|
||||
<span>{Math.round(progress.progress * 100)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-600 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-primary-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress.progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-4 mt-2 text-sm text-slate-400">
|
||||
<span>{scan.hosts_found} hosts found</span>
|
||||
<span>{scan.ports_scanned} ports scanned</span>
|
||||
<span>{new Date(scan.started_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isRunning && (
|
||||
<button
|
||||
onClick={() => handleCancelScan(scan.id)}
|
||||
className="ml-4 p-2 hover:bg-slate-600 rounded-lg transition-colors"
|
||||
title="Cancel scan"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400 hover:text-red-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Common Services */}
|
||||
{statistics && statistics.most_common_services.length > 0 && (
|
||||
<div className="bg-slate-800 rounded-lg p-6 border border-slate-700">
|
||||
<h2 className="text-xl font-semibold text-slate-100 mb-4">Common Services</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{statistics.most_common_services.slice(0, 10).map((service) => (
|
||||
<div
|
||||
key={service.service_name}
|
||||
onClick={() => navigate(`/services/${encodeURIComponent(service.service_name)}`)}
|
||||
className="bg-slate-700/30 rounded-lg p-4 text-center hover:bg-slate-700/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<p className="text-2xl font-bold text-primary-400">{service.count}</p>
|
||||
<p className="text-sm text-slate-300 mt-1">{service.service_name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
teamleader_test/frontend/src/pages/HostDetailPage.tsx
Normal file
227
teamleader_test/frontend/src/pages/HostDetailPage.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Server, Loader2, Activity, MapPin, Shield, Calendar } from 'lucide-react';
|
||||
import { hostApi } from '../services/api';
|
||||
import type { HostWithServices } from '../types/api';
|
||||
import { formatDate, getStatusColor, getStatusBgColor } from '../utils/helpers';
|
||||
|
||||
export default function HostDetailPage() {
|
||||
const { hostId } = useParams<{ hostId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [host, setHost] = useState<HostWithServices | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHost = async () => {
|
||||
if (!hostId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await hostApi.getHost(parseInt(hostId));
|
||||
setHost(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch host:', err);
|
||||
setError('Failed to load host details');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchHost();
|
||||
}, [hostId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-slate-400">Loading host details...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<p className="text-red-400">{error || 'Host not found'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/hosts')}
|
||||
className="flex items-center text-slate-400 hover:text-slate-100 mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Hosts
|
||||
</button>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`p-3 rounded-lg ${getStatusBgColor(host.status)} bg-opacity-20`}>
|
||||
<Server className={`w-8 h-8 ${getStatusColor(host.status)}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-100">
|
||||
{host.hostname || host.ip_address}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
<span className="text-slate-400 font-mono">{host.ip_address}</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(host.status)}`}>
|
||||
{host.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-800 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<MapPin className="w-5 h-5 text-primary-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">MAC Address</p>
|
||||
<p className="text-sm font-mono text-slate-100 mt-1">
|
||||
{host.mac_address || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Shield className="w-5 h-5 text-purple-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">OS / Device</p>
|
||||
<p className="text-sm text-slate-100 mt-1">
|
||||
{host.os_guess || host.device_type || 'Unknown'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Calendar className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">First Seen</p>
|
||||
<p className="text-sm text-slate-100 mt-1">
|
||||
{formatDate(host.first_seen)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-800 rounded-lg p-4 border border-slate-700">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Activity className="w-5 h-5 text-amber-500" />
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Last Seen</p>
|
||||
<p className="text-sm text-slate-100 mt-1">
|
||||
{formatDate(host.last_seen)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vendor Information */}
|
||||
{host.vendor && (
|
||||
<div className="bg-slate-800 rounded-lg p-4 border border-slate-700">
|
||||
<h3 className="text-sm font-medium text-slate-300 mb-2">Vendor</h3>
|
||||
<p className="text-slate-100">{host.vendor}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{host.notes && (
|
||||
<div className="bg-slate-800 rounded-lg p-4 border border-slate-700">
|
||||
<h3 className="text-sm font-medium text-slate-300 mb-2">Notes</h3>
|
||||
<p className="text-slate-100">{host.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services */}
|
||||
<div className="bg-slate-800 rounded-lg border border-slate-700">
|
||||
<div className="p-6 border-b border-slate-700">
|
||||
<h2 className="text-xl font-semibold text-slate-100">
|
||||
Services ({host.services.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{host.services.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Activity className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">No services detected</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Port
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Protocol
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Service
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
State
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700">
|
||||
{host.services.map((service) => (
|
||||
<tr key={service.id} className="hover:bg-slate-700/30">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-primary-400 font-bold">
|
||||
{service.port}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-slate-300 uppercase">
|
||||
{service.protocol}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-slate-100">
|
||||
{service.service_name || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm text-slate-400">
|
||||
{service.service_version || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
service.state === 'open'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-slate-600 text-slate-300'
|
||||
}`}>
|
||||
{service.state}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
teamleader_test/frontend/src/pages/HostsPage.tsx
Normal file
130
teamleader_test/frontend/src/pages/HostsPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Server, Loader2 } from 'lucide-react';
|
||||
import { useHosts } from '../hooks/useHosts';
|
||||
import { formatDate, getStatusColor, getStatusBgColor } from '../utils/helpers';
|
||||
|
||||
export default function HostsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { hosts, loading, error } = useHosts();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredHosts = hosts.filter((host) =>
|
||||
host.ip_address.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(host.hostname && host.hostname.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-slate-400">Loading hosts...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-100">Hosts</h1>
|
||||
<p className="text-slate-400 mt-1">
|
||||
{filteredHosts.length} host{filteredHosts.length !== 1 ? 's' : ''} found
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search hosts..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 bg-slate-700 border border-slate-600 rounded-lg text-slate-100 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-primary-500 w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hosts Table */}
|
||||
<div className="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
{filteredHosts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Server className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">No hosts found</p>
|
||||
<p className="text-sm mt-2">
|
||||
{searchQuery ? 'Try a different search query' : 'Run a scan to discover hosts'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
IP Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Hostname
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
MAC Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Last Seen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700">
|
||||
{filteredHosts.map((host) => (
|
||||
<tr
|
||||
key={host.id}
|
||||
onClick={() => navigate(`/hosts/${host.id}`)}
|
||||
className="hover:bg-slate-700/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusBgColor(host.status)}`} />
|
||||
<span className={`ml-2 text-sm font-medium ${getStatusColor(host.status)}`}>
|
||||
{host.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-slate-100">{host.ip_address}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-slate-300">{host.hostname || '-'}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-slate-400">
|
||||
{host.mac_address || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-slate-400">{formatDate(host.last_seen)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
teamleader_test/frontend/src/pages/NetworkPage.tsx
Normal file
43
teamleader_test/frontend/src/pages/NetworkPage.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import NetworkMap from '../components/NetworkMap';
|
||||
import { useTopology } from '../hooks/useTopology';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function NetworkPage() {
|
||||
const { topology, loading, error, refetch } = useTopology();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-slate-400">Loading network topology...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<div className="text-center">
|
||||
<p className="text-red-400 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={refetch}
|
||||
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-200px)]">
|
||||
<NetworkMap
|
||||
topology={topology!}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
teamleader_test/frontend/src/pages/ScansPage.tsx
Normal file
118
teamleader_test/frontend/src/pages/ScansPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { useScans } from '../hooks/useScans';
|
||||
import { scanApi } from '../services/api';
|
||||
import { formatDate, getScanStatusColor } from '../utils/helpers';
|
||||
|
||||
export default function ScansPage() {
|
||||
const { scans, loading, error, refetch } = useScans();
|
||||
|
||||
const handleCancelScan = async (scanId: number) => {
|
||||
try {
|
||||
await scanApi.cancelScan(scanId);
|
||||
refetch();
|
||||
} catch (err) {
|
||||
console.error('Failed to cancel scan:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-primary-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-slate-400">Loading scans...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-100">Scans</h1>
|
||||
<p className="text-slate-400 mt-1">{scans.length} scan{scans.length !== 1 ? 's' : ''} total</p>
|
||||
</div>
|
||||
|
||||
{/* Scans List */}
|
||||
<div className="space-y-4">
|
||||
{scans.length === 0 ? (
|
||||
<div className="bg-slate-800 rounded-lg border border-slate-700 p-12 text-center">
|
||||
<p className="text-slate-400">No scans found. Start a scan from the Dashboard.</p>
|
||||
</div>
|
||||
) : (
|
||||
scans.map((scan) => (
|
||||
<div
|
||||
key={scan.id}
|
||||
className="bg-slate-800 rounded-lg border border-slate-700 p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-slate-100">{scan.network_range}</h3>
|
||||
<span className="px-2 py-1 bg-slate-700 text-slate-100 text-xs font-medium rounded">
|
||||
{scan.scan_type}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${getScanStatusColor(scan.status)}`}>
|
||||
{scan.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mt-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Hosts Found</p>
|
||||
<p className="text-sm text-slate-100 font-medium mt-1">{scan.hosts_found}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Ports Scanned</p>
|
||||
<p className="text-sm text-slate-100 font-medium mt-1">{scan.ports_scanned}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Started</p>
|
||||
<p className="text-sm text-slate-100 font-medium mt-1">
|
||||
{formatDate(scan.started_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scan.completed_at && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-slate-400">Completed</p>
|
||||
<p className="text-sm text-slate-100 font-medium mt-1">
|
||||
{formatDate(scan.completed_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scan.error_message && (
|
||||
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<p className="text-sm text-red-400">{scan.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scan.status === 'running' && (
|
||||
<button
|
||||
onClick={() => handleCancelScan(scan.id)}
|
||||
className="ml-4 p-2 hover:bg-slate-700 rounded-lg transition-colors"
|
||||
title="Cancel scan"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
teamleader_test/frontend/src/pages/ServiceHostsPage.tsx
Normal file
148
teamleader_test/frontend/src/pages/ServiceHostsPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Server, Loader2 } from 'lucide-react';
|
||||
import { hostApi } from '../services/api';
|
||||
import type { Host } from '../types/api';
|
||||
import { formatDate, getStatusColor, getStatusBgColor } from '../utils/helpers';
|
||||
|
||||
export default function ServiceHostsPage() {
|
||||
const { serviceName } = useParams<{ serviceName: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHosts = async () => {
|
||||
if (!serviceName) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await hostApi.getHostsByService(serviceName);
|
||||
setHosts(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch hosts:', err);
|
||||
setError('Failed to load hosts for this service');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchHosts();
|
||||
}, [serviceName]);
|
||||
|
||||
const handleHostClick = (hostId: number) => {
|
||||
navigate(`/hosts/${hostId}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary-500 animate-spin mx-auto mb-4" />
|
||||
<p className="text-slate-400">Loading hosts...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[calc(100vh-200px)]">
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center text-slate-400 hover:text-slate-100 mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-slate-100">
|
||||
Hosts with Service: {serviceName}
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-1">
|
||||
{hosts.length} host{hosts.length !== 1 ? 's' : ''} found
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hosts Table */}
|
||||
<div className="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
{hosts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Server className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">No hosts found</p>
|
||||
<p className="text-sm mt-2">
|
||||
No hosts are currently providing this service
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
IP Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Hostname
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
MAC Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">
|
||||
Last Seen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700">
|
||||
{hosts.map((host) => (
|
||||
<tr
|
||||
key={host.id}
|
||||
onClick={() => handleHostClick(host.id)}
|
||||
className="hover:bg-slate-700/30 cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className={`w-2 h-2 rounded-full ${getStatusBgColor(host.status)}`} />
|
||||
<span className={`ml-2 text-sm font-medium ${getStatusColor(host.status)}`}>
|
||||
{host.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-slate-100">{host.ip_address}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-slate-300">{host.hostname || '-'}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-mono text-slate-400">
|
||||
{host.mac_address || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-slate-400">{formatDate(host.last_seen)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
teamleader_test/frontend/src/services/api.ts
Normal file
109
teamleader_test/frontend/src/services/api.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Scan,
|
||||
ScanRequest,
|
||||
ScanStartResponse,
|
||||
Host,
|
||||
HostWithServices,
|
||||
Service,
|
||||
Topology,
|
||||
HostStatistics,
|
||||
} from '../types/api';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Scan Endpoints
|
||||
export const scanApi = {
|
||||
startScan: async (request: ScanRequest): Promise<ScanStartResponse> => {
|
||||
const response = await api.post<ScanStartResponse>('/api/scans/start', request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getScanStatus: async (scanId: number): Promise<Scan> => {
|
||||
const response = await api.get<Scan>(`/api/scans/${scanId}/status`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listScans: async (): Promise<Scan[]> => {
|
||||
const response = await api.get<Scan[]>('/api/scans');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cancelScan: async (scanId: number): Promise<{ message: string }> => {
|
||||
const response = await api.delete<{ message: string }>(`/api/scans/${scanId}/cancel`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Host Endpoints
|
||||
export const hostApi = {
|
||||
listHosts: async (params?: {
|
||||
status?: 'up' | 'down';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Host[]> => {
|
||||
const response = await api.get<Host[]>('/api/hosts', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getHost: async (hostId: number): Promise<HostWithServices> => {
|
||||
const response = await api.get<HostWithServices>(`/api/hosts/${hostId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getHostByIp: async (ip: string): Promise<HostWithServices> => {
|
||||
const response = await api.get<HostWithServices>(`/api/hosts/ip/${ip}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getHostServices: async (hostId: number): Promise<Service[]> => {
|
||||
const response = await api.get<Service[]>(`/api/hosts/${hostId}/services`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getHostStatistics: async (): Promise<HostStatistics> => {
|
||||
const response = await api.get<HostStatistics>('/api/hosts/statistics');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteHost: async (hostId: number): Promise<{ message: string }> => {
|
||||
const response = await api.delete<{ message: string }>(`/api/hosts/${hostId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getHostsByService: async (serviceName: string): Promise<Host[]> => {
|
||||
const response = await api.get<Host[]>(`/api/hosts/by-service/${encodeURIComponent(serviceName)}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Topology Endpoints
|
||||
export const topologyApi = {
|
||||
getTopology: async (): Promise<Topology> => {
|
||||
const response = await api.get<Topology>('/api/topology');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getNeighbors: async (hostId: number): Promise<Host[]> => {
|
||||
const response = await api.get<Host[]>(`/api/topology/neighbors/${hostId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Health Check
|
||||
export const healthApi = {
|
||||
check: async (): Promise<{ status: string }> => {
|
||||
const response = await api.get<{ status: string }>('/health');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
125
teamleader_test/frontend/src/services/websocket.ts
Normal file
125
teamleader_test/frontend/src/services/websocket.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
WSMessage,
|
||||
WSScanProgress,
|
||||
WSScanComplete,
|
||||
WSHostDiscovered,
|
||||
WSError,
|
||||
} from '../types/api';
|
||||
|
||||
const WS_BASE_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000';
|
||||
|
||||
export type WSMessageHandler = {
|
||||
onScanProgress?: (data: WSScanProgress) => void;
|
||||
onScanComplete?: (data: WSScanComplete) => void;
|
||||
onHostDiscovered?: (data: WSHostDiscovered) => void;
|
||||
onError?: (data: WSError) => void;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
};
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private handlers: WSMessageHandler = {};
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 2000;
|
||||
private reconnectTimer: number | null = null;
|
||||
|
||||
constructor(handlers: WSMessageHandler) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(`${WS_BASE_URL}/api/ws`);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
this.reconnectAttempts = 0;
|
||||
this.handlers.onConnect?.();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WSMessage = JSON.parse(event.data);
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this.handlers.onDisconnect?.();
|
||||
this.attemptReconnect();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket connection:', error);
|
||||
this.attemptReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: WSMessage): void {
|
||||
switch (message.type) {
|
||||
case 'scan_progress':
|
||||
this.handlers.onScanProgress?.(message.data as WSScanProgress);
|
||||
break;
|
||||
case 'scan_complete':
|
||||
this.handlers.onScanComplete?.(message.data as WSScanComplete);
|
||||
break;
|
||||
case 'host_discovered':
|
||||
this.handlers.onHostDiscovered?.(message.data as WSHostDiscovered);
|
||||
break;
|
||||
case 'error':
|
||||
this.handlers.onError?.(message.data as WSError);
|
||||
break;
|
||||
default:
|
||||
console.warn('Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
private attemptReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('Max reconnection attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * this.reconnectAttempts;
|
||||
|
||||
console.log(`Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
134
teamleader_test/frontend/src/types/api.ts
Normal file
134
teamleader_test/frontend/src/types/api.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
// API Response Types
|
||||
export interface Host {
|
||||
id: number;
|
||||
ip_address: string;
|
||||
hostname: string | null;
|
||||
mac_address: string | null;
|
||||
status: 'online' | 'offline' | 'scanning';
|
||||
last_seen: string;
|
||||
first_seen: string;
|
||||
device_type: string | null;
|
||||
os_guess: string | null;
|
||||
vendor: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: number;
|
||||
host_id: number;
|
||||
port: number;
|
||||
protocol: string;
|
||||
service_name: string | null;
|
||||
service_version: string | null;
|
||||
state: string;
|
||||
banner: string | null;
|
||||
}
|
||||
|
||||
export interface HostWithServices extends Host {
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: number;
|
||||
source_host_id: number;
|
||||
target_host_id: number;
|
||||
connection_type: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface Scan {
|
||||
id: number;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
scan_type: 'quick' | 'standard' | 'deep' | 'custom';
|
||||
network_range: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
hosts_found: number;
|
||||
ports_scanned: number;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface ScanRequest {
|
||||
network_range: string;
|
||||
scan_type?: 'quick' | 'standard' | 'deep' | 'custom';
|
||||
port_range?: string;
|
||||
include_service_detection?: boolean;
|
||||
use_nmap?: boolean;
|
||||
}
|
||||
|
||||
export interface ScanStartResponse {
|
||||
scan_id: number;
|
||||
message: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
}
|
||||
|
||||
export interface TopologyNode {
|
||||
id: string;
|
||||
ip: string;
|
||||
hostname: string | null;
|
||||
type: 'gateway' | 'server' | 'workstation' | 'device' | 'unknown';
|
||||
status: 'online' | 'offline' | 'up' | 'down';
|
||||
service_count: number;
|
||||
connections: number;
|
||||
}
|
||||
|
||||
export interface TopologyEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface Topology {
|
||||
nodes: TopologyNode[];
|
||||
edges: TopologyEdge[];
|
||||
statistics: {
|
||||
total_nodes: number;
|
||||
total_edges: number;
|
||||
isolated_nodes: number;
|
||||
avg_connections: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HostStatistics {
|
||||
total_hosts: number;
|
||||
online_hosts: number;
|
||||
offline_hosts: number;
|
||||
total_services: number;
|
||||
total_scans: number;
|
||||
last_scan: string | null;
|
||||
most_common_services: Array<{
|
||||
service_name: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// WebSocket Message Types
|
||||
export interface WSMessage {
|
||||
type: 'scan_progress' | 'scan_complete' | 'host_discovered' | 'error';
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface WSScanProgress {
|
||||
scan_id: number;
|
||||
progress: number;
|
||||
hosts_scanned: number;
|
||||
total_hosts: number;
|
||||
current_host?: string;
|
||||
}
|
||||
|
||||
export interface WSScanComplete {
|
||||
scan_id: number;
|
||||
total_hosts: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface WSHostDiscovered {
|
||||
scan_id: number;
|
||||
host: Host;
|
||||
}
|
||||
|
||||
export interface WSError {
|
||||
message: string;
|
||||
scan_id?: number;
|
||||
}
|
||||
87
teamleader_test/frontend/src/utils/helpers.ts
Normal file
87
teamleader_test/frontend/src/utils/helpers.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function getStatusColor(status: 'online' | 'offline' | 'scanning'): string {
|
||||
if (status === 'online') return 'text-green-500';
|
||||
if (status === 'offline') return 'text-red-500';
|
||||
return 'text-yellow-500';
|
||||
}
|
||||
|
||||
export function getStatusBgColor(status: 'online' | 'offline' | 'scanning'): string {
|
||||
if (status === 'online') return 'bg-green-500';
|
||||
if (status === 'offline') return 'bg-red-500';
|
||||
return 'bg-yellow-500';
|
||||
}
|
||||
|
||||
export function getScanStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'text-green-500';
|
||||
case 'running':
|
||||
return 'text-blue-500';
|
||||
case 'failed':
|
||||
return 'text-red-500';
|
||||
case 'cancelled':
|
||||
return 'text-yellow-500';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeTypeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'gateway':
|
||||
return '#3b82f6'; // blue
|
||||
case 'server':
|
||||
return '#10b981'; // green
|
||||
case 'workstation':
|
||||
return '#8b5cf6'; // purple
|
||||
case 'device':
|
||||
return '#f59e0b'; // amber
|
||||
default:
|
||||
return '#6b7280'; // gray
|
||||
}
|
||||
}
|
||||
|
||||
export function getNodeTypeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'gateway':
|
||||
return '🌐';
|
||||
case 'server':
|
||||
return '🖥️';
|
||||
case 'workstation':
|
||||
return '💻';
|
||||
case 'device':
|
||||
return '📱';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
}
|
||||
10
teamleader_test/frontend/src/vite-env.d.ts
vendored
Normal file
10
teamleader_test/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_WS_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user