feat: complete deployment codebase for BackOne DPI dashboard with dynamic telemetry, rebranding, database size tracking, and modular design
This commit is contained in:
1 parent
6cf3c6c7e5
commit
c316f3171b
226 files changed
+21152
-8397
No files matched your search
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
firstSeen?: string;
|
||||
lastSeen?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function formatActiveDuration(firstSeen?: string, lastSeen?: string) {
|
||||
if (!firstSeen || !lastSeen) return null;
|
||||
const start = new Date(firstSeen).getTime();
|
||||
const end = new Date(lastSeen).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
const dateObj = new Date(lastSeen);
|
||||
const fullTimestamp = dateObj.toLocaleString("en-US", {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
|
||||
let diffSec = Math.floor((end - start) / 1000);
|
||||
if (diffSec < 0) diffSec = 0;
|
||||
|
||||
const h = Math.floor(diffSec / 3600);
|
||||
const m = Math.floor((diffSec % 3600) / 60);
|
||||
const s = diffSec % 60;
|
||||
|
||||
let durationText = "Just now";
|
||||
if (h > 0) durationText = `${h}h ${m}m ${s}s`;
|
||||
else if (m > 0) durationText = `${m}m ${s}s`;
|
||||
else if (s > 0) durationText = `${s}s`;
|
||||
|
||||
const timeSinceLastSeen = Math.floor((now - end) / 1000);
|
||||
const isActive = timeSinceLastSeen <= 300;
|
||||
|
||||
let lastSeenText = "";
|
||||
if (!isActive) {
|
||||
const agoH = Math.floor(timeSinceLastSeen / 3600);
|
||||
const agoM = Math.floor((timeSinceLastSeen % 3600) / 60);
|
||||
if (agoH > 24) {
|
||||
const agoD = Math.floor(agoH / 24);
|
||||
lastSeenText = `${agoD}d ago`;
|
||||
} else if (agoH > 0) {
|
||||
lastSeenText = `${agoH}h ago`;
|
||||
} else if (agoM > 0) {
|
||||
lastSeenText = `${agoM}m ago`;
|
||||
} else {
|
||||
lastSeenText = "a few seconds ago";
|
||||
}
|
||||
}
|
||||
|
||||
return { isActive, durationText, fullTimestamp, lastSeenText };
|
||||
}
|
||||
|
||||
export default function ActiveDurationDisplay({ firstSeen, lastSeen, className = "" }: Props) {
|
||||
if (!firstSeen || !lastSeen) return null;
|
||||
const dur = formatActiveDuration(firstSeen, lastSeen);
|
||||
if (!dur) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 bg-secondary/30 dark:bg-black/20 p-2.5 rounded-lg border border-border text-xs ${className}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={`flex items-center gap-1.5 ${dur.isActive ? 'text-blue-600 dark:text-blue-400' : 'text-muted-foreground'}`}>
|
||||
<Clock className="w-3.5 h-3.5" /> {dur.isActive ? "Active" : "Last seen"}
|
||||
</span>
|
||||
<span className="text-card-foreground font-medium">
|
||||
{dur.isActive ? dur.durationText : dur.lastSeenText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/60 text-right">
|
||||
{dur.fullTimestamp}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +1,42 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchAgentDetails, AgentDetails } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { fmtBytes, getAppFavicon } from "@/lib/utils";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { AppDetailModal } from "@/components/ui/AppDetailModal";
|
||||
import {
|
||||
Loader2, X, Server, Monitor, Activity, Globe, Shield,
|
||||
Bell, Wifi, AlertTriangle, Lock, Unlock, ChevronRight,
|
||||
Eye, Router
|
||||
Bell, Wifi, ChevronRight, LineChart
|
||||
} from "lucide-react";
|
||||
|
||||
import AgentDevicesTab from "./AgentDevicesTab";
|
||||
import AgentFlowsTab from "./AgentFlowsTab";
|
||||
import AgentSecurityTab from "./AgentSecurityTab";
|
||||
import AgentEventsTab from "./AgentEventsTab";
|
||||
import AgentMacTab from "./AgentMacTab";
|
||||
import AgentTelemetryTab from "./AgentTelemetryTab";
|
||||
|
||||
interface Props {
|
||||
agentUuid: string;
|
||||
agentLabel: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "devices" | "flows" | "apps" | "security" | "events" | "mac";
|
||||
|
||||
// ── Severity colour helper ─────────────────────────────────────────────────
|
||||
function severityStyle(sev: string | null) {
|
||||
switch ((sev || "").toLowerCase()) {
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "high": return { badge: "bg-orange-500/15 text-orange-400 ring-orange-500/30", dot: "bg-orange-400" };
|
||||
case "warning": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "info": return { badge: "bg-blue-500/15 text-blue-400 ring-blue-500/30", dot: "bg-blue-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
function riskStyle(level: string | null) {
|
||||
switch ((level || "").toLowerCase()) {
|
||||
case "vulnerable":
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "moderate":
|
||||
case "medium": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "safe":
|
||||
case "low": return { badge: "bg-green-500/15 text-green-400 ring-green-500/30", dot: "bg-green-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared table wrapper ────────────────────────────────────────────────────
|
||||
function TableWrap({ children, head }: { children: React.ReactNode; head: string[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border/50">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{head.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
type Tab = "devices" | "flows" | "apps" | "security" | "events" | "mac" | "telemetry";
|
||||
|
||||
function EmptyState({ msg }: { msg: string }) {
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">{msg}</p>;
|
||||
}
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────────────
|
||||
export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
const [data, setData] = useState<AgentDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("devices");
|
||||
const [selectedDevice, setSelectedDevice] = useState<{ ip: string; label: string | null; mac: string | null } | null>(null);
|
||||
const [selectedDevice, setSelectedDevice] = useState<{ ip: string; label: string | null; mac: string | null; agent_uuid?: string } | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [selectedAppLabel, setSelectedAppLabel] = useState<string | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
@@ -89,7 +54,12 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [agentUuid]);
|
||||
|
||||
// Count security alerts
|
||||
// Reset tab scroll/state when switching tabs
|
||||
useEffect(() => {
|
||||
const el = document.querySelector(".custom-scrollbar");
|
||||
if (el) el.scrollTop = 0;
|
||||
}, [activeTab]);
|
||||
|
||||
const secCount = data
|
||||
? (data.security?.insecure_protocols?.length ?? 0) +
|
||||
(data.security?.unencrypted_passwords?.length ?? 0) +
|
||||
@@ -98,12 +68,13 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
: 0;
|
||||
|
||||
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number }[] = [
|
||||
{ key: "devices", label: "Devices", icon: <Monitor className="w-3.5 h-3.5" />, count: data?.devices.length },
|
||||
{ key: "flows", label: "Flows", icon: <Activity className="w-3.5 h-3.5" />, count: data?.flows.length },
|
||||
{ key: "apps", label: "Top Apps", icon: <Globe className="w-3.5 h-3.5" />, count: data?.top_apps.length },
|
||||
{ key: "security", label: "Security", icon: <Shield className="w-3.5 h-3.5" />, count: secCount || undefined },
|
||||
{ key: "events", label: "Events", icon: <Bell className="w-3.5 h-3.5" />, count: data?.events.length },
|
||||
{ key: "mac", label: "MAC Bandwidth", icon: <Wifi className="w-3.5 h-3.5" />, count: data?.mac_bandwidth.length },
|
||||
{ key: "devices", label: "Devices", icon: <Monitor className="w-3.5 h-3.5" />, count: data?.devices?.length },
|
||||
{ key: "flows", label: "Flows", icon: <Activity className="w-3.5 h-3.5" />, count: data?.flows?.length },
|
||||
{ key: "apps", label: "Top Apps", icon: <Globe className="w-3.5 h-3.5" />, count: data?.top_apps?.length },
|
||||
{ key: "security", label: "Security", icon: <Shield className="w-3.5 h-3.5" />, count: secCount || undefined },
|
||||
{ key: "events", label: "Events", icon: <Bell className="w-3.5 h-3.5" />, count: data?.events?.length },
|
||||
{ key: "mac", label: "MAC Bandwidth", icon: <Wifi className="w-3.5 h-3.5" />, count: data?.mac_bandwidth?.length },
|
||||
{ key: "telemetry", label: "Telemetry", icon: <LineChart className="w-3.5 h-3.5" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -114,7 +85,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-blue-900/30 to-purple-900/20 rounded-t-2xl flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg ring-1 ring-blue-500/30">
|
||||
@@ -130,18 +101,17 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
||||
<p className="text-muted-foreground text-sm">Loading agent data…</p>
|
||||
<p className="text-muted-foreground text-sm">Loading agent data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* ── KPI row ── */}
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b border-border/50 flex-shrink-0">
|
||||
{[
|
||||
{ label: "Devices", value: data.summary?.total_devices || data.devices.length, color: "text-card-foreground" },
|
||||
@@ -156,15 +126,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Security alert strip ── */}
|
||||
{secCount > 0 && (
|
||||
<div className="mx-6 mt-3 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-xs text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
<span><strong>{secCount}</strong> security alerts detected on this agent — check the Security tab</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-6 py-3 border-b border-border/50 flex-shrink-0 flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
@@ -187,56 +149,42 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tab content ── */}
|
||||
{/* Tab content */}
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
|
||||
{/* DEVICES */}
|
||||
{activeTab === "devices" && (
|
||||
data.devices.length === 0 ? <EmptyState msg="No devices found for this agent." /> : (
|
||||
<div className="space-y-2">
|
||||
{data.devices.map((d, i) => {
|
||||
const rc = riskStyle(d.risk_level);
|
||||
<AgentDevicesTab
|
||||
devices={data.devices}
|
||||
onSelectDevice={(d) => setSelectedDevice({ ip: d.ip, label: d.label, mac: d.mac, agent_uuid: agentUuid })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "flows" && (
|
||||
<AgentFlowsTab flows={data.flows} />
|
||||
)}
|
||||
|
||||
{activeTab === "apps" && (
|
||||
data.top_apps.length === 0 ? <EmptyState msg="No applications accessed by this agent." /> : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{data.top_apps.map((app) => {
|
||||
const appTotal = app.download + app.upload;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedDevice({ ip: d.ip_address || "", label: d.device_label, mac: d.mac_address })}
|
||||
className="w-full text-left p-3 bg-white/[0.03] hover:bg-white/[0.07] hover:border-cyan-500/30 border border-border/50 rounded-xl transition-all group cursor-pointer"
|
||||
key={app.app_id}
|
||||
onClick={() => setSelectedAppLabel(app.app_label)}
|
||||
className="flex items-center gap-3 p-3 bg-white/[0.03] hover:bg-white/[0.07] border border-border/50 rounded-xl transition-all group w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-slate-800/60 group-hover:bg-cyan-500/10 rounded-lg transition-colors">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground group-hover:text-cyan-400 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors">{d.device_label || d.ip_address || "Unknown"}</p>
|
||||
{d.risk_level && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 ${rc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
|
||||
{d.risk_level}
|
||||
</span>
|
||||
)}
|
||||
{d.has_insecure && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 bg-orange-500/10 ring-orange-500/30 text-orange-400">
|
||||
âš Insecure
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
<p className="text-xs text-muted-foreground font-mono">{d.ip_address}</p>
|
||||
{d.mac_address && <p className="text-xs text-muted-foreground font-mono">{d.mac_address}</p>}
|
||||
{d.os_label && <p className="text-xs text-blue-400">{d.os_label}</p>}
|
||||
{d.manufacturer && <p className="text-xs text-muted-foreground">{d.manufacturer}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 space-y-0.5">
|
||||
<p className="text-xs text-cyan-400">↓ {fmtBytes(d.download ?? 0)}</p>
|
||||
<p className="text-xs text-green-400">↑ {fmtBytes(d.upload ?? 0)}</p>
|
||||
{d.encrypted_pct !== null && (
|
||||
<p className="text-xs text-muted-foreground">🔒 {d.encrypted_pct}%</p>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 ml-auto transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors">{app.app_label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{app.category || "Web"}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-card-foreground font-semibold">{fmtBytes(appTotal)}</p>
|
||||
<span className="text-[10px] text-muted-foreground flex gap-2">
|
||||
<span className="text-cyan-400">DL: {fmtBytes(app.download)}</span>
|
||||
<span className="text-green-400">UL: {fmtBytes(app.upload)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 transition-colors" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -244,361 +192,21 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
)
|
||||
)}
|
||||
|
||||
{/* FLOWS */}
|
||||
{activeTab === "flows" && (
|
||||
data.flows.length === 0 ? <EmptyState msg="No flows found for this agent." /> : (
|
||||
<TableWrap head={["Source IP", "Destination", "App / Domain", "Proto", "↓", "↑", "Last Seen"]}>
|
||||
{data.flows.map((f, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400 whitespace-nowrap">{f.src_ip || "—"}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-slate-300">
|
||||
{f.dst_ip || "—"}{f.dst_port ? <span className="text-muted-foreground">:{f.dst_port}</span> : ""}
|
||||
</span>
|
||||
{f.dst_ip && <IpDetails ip={f.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap max-w-[200px]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold text-purple-300 text-xs truncate" title={f.app_label || "—"}>
|
||||
{f.app_label || "—"}
|
||||
</span>
|
||||
{f.domain && (
|
||||
<span className="text-[10px] text-muted-foreground italic truncate" title={f.domain}>
|
||||
{f.domain}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{f.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* TOP APPS */}
|
||||
{activeTab === "apps" && (
|
||||
data.top_apps.length === 0 ? <EmptyState msg="No application data for this agent." /> : (
|
||||
<div className="space-y-2">
|
||||
{data.top_apps.map((a, i) => {
|
||||
const total = (a.download ?? 0) + (a.upload ?? 0);
|
||||
const maxTotal = Math.max(...data.top_apps.map(x => (x.download ?? 0) + (x.upload ?? 0)), 1);
|
||||
const pct = (total / maxTotal) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{a.favicon
|
||||
? <img src={a.favicon} alt={a.app_label} className="w-5 h-5 rounded bg-white p-0.5 flex-shrink-0" />
|
||||
: <div className="w-5 h-5 rounded bg-slate-700 flex-shrink-0" />
|
||||
}
|
||||
<span className="font-medium text-card-foreground text-sm flex-1">{a.app_label}</span>
|
||||
{a.category && <span className="text-xs text-muted-foreground bg-secondary px-2 py-0.5 rounded">{a.category}</span>}
|
||||
<span className="text-xs font-mono text-card-foreground">{fmtBytes(total)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground mb-2">
|
||||
<span className="text-cyan-400">↓ {fmtBytes(a.download ?? 0)}</span>
|
||||
<span className="text-green-400">↑ {fmtBytes(a.upload ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* SECURITY */}
|
||||
{activeTab === "security" && (
|
||||
<div className="space-y-6">
|
||||
{/* Encryption Audit */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Lock className="w-4 h-4 text-blue-400" /> Encryption Audit
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.encryption_audit.length ?? 0} devices</span>
|
||||
</h4>
|
||||
{(data.security?.encryption_audit.length ?? 0) === 0 ? <EmptyState msg="No encryption audit data for this agent." /> : (
|
||||
<TableWrap head={["IP Address", "Device", "Encrypted %", "Risk", "Enc Bytes", "Unenc Bytes"]}>
|
||||
{data.security!.encryption_audit.map((r, i) => {
|
||||
const rc = riskStyle(r.risk_level);
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.device_label || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${r.encrypted_pct ?? 0}%` }} />
|
||||
</div>
|
||||
<span className="text-card-foreground">{r.encrypted_pct ?? 0}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${rc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
|
||||
{r.risk_level || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-cyan-400">{fmtBytes(r.encrypted ?? 0)}</td>
|
||||
<td className="px-3 py-2 text-xs text-orange-400">{fmtBytes(r.unencrypted ?? 0)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Insecure Protocols */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Unlock className="w-4 h-4 text-orange-400" /> Insecure Protocols
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.insecure_protocols.length ?? 0} records</span>
|
||||
</h4>
|
||||
{(data.security?.insecure_protocols.length ?? 0) === 0 ? <EmptyState msg="No insecure protocols detected for this agent." /> : (
|
||||
<TableWrap head={["IP", "Protocol", "Risk", "App", "Dst IP:Port", "↓", "↑"]}>
|
||||
{data.security!.insecure_protocols.map((r, i) => {
|
||||
const sc = severityStyle(r.risk);
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-200">{r.protocol || "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{r.risk || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-purple-300 truncate max-w-[100px]">{r.app_label || "—"}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</span>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-cyan-400">{fmtBytes(r.download)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-green-400">{fmtBytes(r.upload)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Unencrypted Passwords */}
|
||||
{(data.security?.unencrypted_passwords.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-red-400 mb-3">
|
||||
<AlertTriangle className="w-4 h-4" /> Unencrypted Passwords Detected!
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security!.unencrypted_passwords.length} records</span>
|
||||
</h4>
|
||||
<TableWrap head={["IP", "Dst IP:Port", "Protocol", "Username", "Severity"]}>
|
||||
{data.security!.unencrypted_passwords.map((r, i) => {
|
||||
const sc = severityStyle(r.severity);
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</span>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300">{r.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-yellow-300 font-mono">{r.username || "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{r.severity || "—"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* IP Reputation */}
|
||||
{(data.security?.ip_reputation.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Eye className="w-4 h-4 text-purple-400" /> IP Reputation
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security!.ip_reputation.length} records</span>
|
||||
</h4>
|
||||
<TableWrap head={["Remote IP", "Local IP", "Reputation", "Score", "Country", "Blacklisted"]}>
|
||||
{data.security!.ip_reputation.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 font-mono text-xs text-red-400">{r.ip_address || "—"}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.local_ip || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300">{r.reputation || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-yellow-400">{r.score ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.country || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{r.blacklisted
|
||||
? <span className="text-red-400 font-semibold">â›” Yes</span>
|
||||
: <span className="text-green-400">✓ No</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tor & VPN side-by-side */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Tor */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Router className="w-4 h-4 text-red-400" /> Tor Detections
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.tor_detections.length ?? 0}</span>
|
||||
</h4>
|
||||
{(data.security?.tor_detections.length ?? 0) === 0
|
||||
? <EmptyState msg="No Tor usage detected." />
|
||||
: <div className="space-y-2">{data.security!.tor_detections.map((r, i) => (
|
||||
<div key={i} className="p-2 bg-red-500/5 border border-red-500/20 rounded-lg text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
|
||||
<span className="text-muted-foreground">{r.country || "—"}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5">Exit: {r.exit_node || "—"}</div>
|
||||
</div>
|
||||
))}</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
{/* VPN */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> VPN Detections
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.vpn_detections.length ?? 0}</span>
|
||||
</h4>
|
||||
{(data.security?.vpn_detections.length ?? 0) === 0
|
||||
? <EmptyState msg="No VPN usage detected." />
|
||||
: <div className="space-y-2">{data.security!.vpn_detections.map((r, i) => (
|
||||
<div key={i} className="p-2 bg-blue-500/5 border border-blue-500/20 rounded-lg text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
|
||||
<span className="text-purple-400">{r.vpn_type || "—"}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 flex flex-col gap-0.5">
|
||||
<span>Remote: {r.remote_ip || "—"} • {r.country || "—"}</span>
|
||||
{r.remote_ip && <IpDetails ip={r.remote_ip} />}
|
||||
</div>
|
||||
{r.confidence && <div className="text-muted-foreground">Confidence: {(r.confidence * 100).toFixed(0)}%</div>}
|
||||
</div>
|
||||
))}</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Server Discovery */}
|
||||
{(data.server_discovery?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Server className="w-4 h-4 text-cyan-400" /> Servers Discovered
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.server_discovery!.length} records</span>
|
||||
</h4>
|
||||
<TableWrap head={["IP", "Hostname", "Server Type", "Port", "Protocol", "OS"]}>
|
||||
{data.server_discovery!.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{r.ip_address || "—"}</span>
|
||||
{r.ip_address && <IpDetails ip={r.ip_address} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.hostname || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-cyan-400">{r.server_type || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.port ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-purple-300">{r.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.os_label || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<AgentSecurityTab data={data} />
|
||||
)}
|
||||
|
||||
{/* EVENTS */}
|
||||
{activeTab === "events" && (
|
||||
data.events.length === 0 ? <EmptyState msg="No events found for this agent." /> : (
|
||||
<div className="space-y-2">
|
||||
{data.events.map((ev, i) => {
|
||||
const sc = severityStyle(ev.severity);
|
||||
return (
|
||||
<div key={i} className={`p-3 border rounded-xl text-xs transition-colors ${
|
||||
(ev.severity || "").toLowerCase() === "critical" ? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10" :
|
||||
(ev.severity || "").toLowerCase() === "high" ? "bg-orange-500/5 border-orange-500/20 hover:bg-orange-500/10" :
|
||||
"bg-white/[0.03] border-border/50 hover:bg-white/[0.06]"
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 flex-shrink-0 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{ev.severity || "—"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-semibold text-card-foreground">{ev.event_type || "Unknown"}</span>
|
||||
<span className="font-mono text-blue-400">{ev.ip_address || "—"}</span>
|
||||
{ev.mac_address && <span className="font-mono text-muted-foreground">{ev.mac_address}</span>}
|
||||
</div>
|
||||
{ev.description && <p className="text-muted-foreground leading-relaxed">{ev.description}</p>}
|
||||
</div>
|
||||
<span className="text-slate-600 flex-shrink-0 whitespace-nowrap">{ev.event_at?.slice(0, 16) || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
<AgentEventsTab events={data.events} />
|
||||
)}
|
||||
|
||||
{/* MAC BANDWIDTH */}
|
||||
{activeTab === "mac" && (
|
||||
data.mac_bandwidth.length === 0 ? <EmptyState msg="No MAC bandwidth data for this agent." /> : (
|
||||
<div className="space-y-2">
|
||||
{data.mac_bandwidth.map((m, i) => {
|
||||
const maxTotal = Math.max(...data.mac_bandwidth.map(x => x.total ?? 0), 1);
|
||||
const pct = ((m.total ?? 0) / maxTotal) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Wifi className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-xs text-slate-200">{m.mac_address}</p>
|
||||
<p className="text-xs text-muted-foreground">{m.manufacturer || "Unknown Manufacturer"}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-card-foreground font-medium">{fmtBytes(m.total ?? 0)}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs mb-2">
|
||||
<span className="text-cyan-400">↓ {fmtBytes(m.download ?? 0)}</span>
|
||||
<span className="text-green-400">↑ {fmtBytes(m.upload ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
<AgentMacTab macBandwidth={data.mac_bandwidth} />
|
||||
)}
|
||||
|
||||
{activeTab === "telemetry" && (
|
||||
<AgentTelemetryTab agentUuid={agentUuid} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
@@ -608,14 +216,19 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
{selectedDevice && selectedDevice.ip && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip}
|
||||
deviceLabel={selectedDevice.label}
|
||||
macAddress={selectedDevice.mac}
|
||||
mac={selectedDevice.mac || ''}
|
||||
deviceData={{ agent_uuid: selectedDevice.agent_uuid || agentUuid } as any}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
{selectedAppLabel && (
|
||||
<AppDetailModal
|
||||
appLabel={selectedAppLabel}
|
||||
favicon={getAppFavicon(selectedAppLabel)}
|
||||
agentUuid={agentUuid}
|
||||
onClose={() => setSelectedAppLabel(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Monitor, ChevronRight } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
devices: any[];
|
||||
onSelectDevice: (device: { ip: string; label: string | null; mac: string | null }) => void;
|
||||
}
|
||||
|
||||
function riskStyle(level: string | null) {
|
||||
switch ((level || "").toLowerCase()) {
|
||||
case "vulnerable":
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "moderate":
|
||||
case "medium": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "safe":
|
||||
case "low": return { badge: "bg-green-500/15 text-green-400 ring-green-500/30", dot: "bg-green-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentDevicesTab({ devices, onSelectDevice }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(devices.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = devices.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{paginated.map((d, i) => {
|
||||
const rc = riskStyle(d.risk_level);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelectDevice({ ip: d.ip_address || "", label: d.device_label, mac: d.mac_address })}
|
||||
className="w-full text-left p-3 bg-white/[0.03] hover:bg-white/[0.07] hover:border-cyan-500/30 border border-border/50 rounded-xl transition-all group cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-slate-800/60 group-hover:bg-cyan-500/10 rounded-lg transition-colors">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground group-hover:text-cyan-400 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors" title={d.device_label || d.ip_address || "Unknown"}>
|
||||
{d.device_label || d.ip_address || "Unknown"}
|
||||
</p>
|
||||
{d.risk_level && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 ${rc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
|
||||
{d.risk_level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
<p className="text-xs text-muted-foreground font-mono">{d.ip_address}</p>
|
||||
{d.mac_address && <p className="text-xs text-muted-foreground font-mono">{d.mac_address}</p>}
|
||||
{d.os_label && <p className="text-xs text-blue-400">{d.os_label}</p>}
|
||||
{d.manufacturer && <p className="text-xs text-muted-foreground">{d.manufacturer}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 space-y-1 min-w-[110px]">
|
||||
<p className="text-xs text-cyan-400 flex items-center justify-end gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">DL:</span> {fmtBytes(d.download ?? 0)}
|
||||
</p>
|
||||
<p className="text-xs text-green-400 flex items-center justify-end gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">UL:</span> {fmtBytes(d.upload ?? 0)}
|
||||
</p>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 ml-auto transition-colors mt-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={devices.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
events: any[];
|
||||
}
|
||||
|
||||
function severityStyle(sev: string | null) {
|
||||
switch ((sev || "").toLowerCase()) {
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "high": return { badge: "bg-orange-500/15 text-orange-400 ring-orange-500/30", dot: "bg-orange-400" };
|
||||
case "warning": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "info": return { badge: "bg-blue-500/15 text-blue-400 ring-blue-500/30", dot: "bg-blue-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentEventsTab({ events }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(events.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = events.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{paginated.map((ev, i) => {
|
||||
const sc = severityStyle(ev.severity);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`p-3 border rounded-xl text-xs transition-colors ${
|
||||
(ev.severity || "").toLowerCase() === "critical"
|
||||
? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10"
|
||||
: (ev.severity || "").toLowerCase() === "high"
|
||||
? "bg-orange-500/5 border-orange-500/20 hover:bg-orange-500/10"
|
||||
: "bg-white/[0.03] border-border/50 hover:bg-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 flex-shrink-0 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{ev.severity || "—"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-semibold text-card-foreground">{ev.event_type || "Unknown"}</span>
|
||||
<span className="font-mono text-blue-400">{ev.ip_address || "—"}</span>
|
||||
{ev.mac_address && <span className="font-mono text-muted-foreground">{ev.mac_address}</span>}
|
||||
</div>
|
||||
{ev.description && <p className="text-muted-foreground leading-relaxed">{ev.description}</p>}
|
||||
</div>
|
||||
<span className="text-slate-600 flex-shrink-0 truncate">
|
||||
{ev.event_at ? ev.event_at.slice(0, 16).replace("T", " ") : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={events.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { IpDetails } from "./IpDetails";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cell with overflow ellipsis + title tooltip ────────────────────────────────
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
flows: any[];
|
||||
}
|
||||
|
||||
export default function AgentFlowsTab({ flows }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(flows.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = flows.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "Source IP", width: "min-w-[100px]" },
|
||||
{ header: "Destination", width: "min-w-[140px]" },
|
||||
{ header: "App / Domain", width: "min-w-[160px] w-full" },
|
||||
{ header: "Protocol", width: "w-[80px]" },
|
||||
{ header: "Download", width: "w-[90px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[90px]", align: "right" },
|
||||
{ header: "Last Seen", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TableWrap cols={COLS}>
|
||||
{paginated.map((f, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={f.src_ip || "—"} className="font-mono text-blue-400" /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 truncate min-w-0">
|
||||
<div className="font-mono text-slate-300 truncate" title={`${f.dst_ip || "—"}${f.dst_port ? ':' + f.dst_port : ''}`}>
|
||||
{f.dst_ip || "—"}{f.dst_port ? <span className="text-muted-foreground">:{f.dst_port}</span> : ""}
|
||||
</div>
|
||||
{f.dst_ip && <IpDetails ip={f.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 truncate min-w-0">
|
||||
<div className="font-semibold text-purple-300 truncate" title={f.app_label || "—"}>
|
||||
{f.app_label || "—"}
|
||||
</div>
|
||||
{f.domain && (
|
||||
<div className="text-[10px] text-muted-foreground italic truncate" title={f.domain}>
|
||||
{f.domain}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.protocol || "—"} className="text-muted-foreground uppercase text-[10px]" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.last_seen ? f.last_seen.slice(0, 16).replace("T", " ") : "—"} className="text-slate-400" /></td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
{totalPages > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={flows.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Wifi } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
macBandwidth: any[];
|
||||
}
|
||||
|
||||
export default function AgentMacTab({ macBandwidth }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(macBandwidth.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = macBandwidth.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{paginated.map((m, i) => {
|
||||
const maxTotal = Math.max(...macBandwidth.map(x => x.total ?? 0), 1);
|
||||
const pct = ((m.total ?? 0) / maxTotal) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Wifi className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-xs text-slate-200">{m.mac_address}</p>
|
||||
<p className="text-xs text-muted-foreground">{m.manufacturer || "Unknown Manufacturer"}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-card-foreground font-medium">{fmtBytes(m.total ?? 0)}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs mb-2">
|
||||
<span className="text-cyan-400">DL: {fmtBytes(m.download ?? 0)}</span>
|
||||
<span className="text-green-400">UL: {fmtBytes(m.upload ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={macBandwidth.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Lock, Eye, Router } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { IpDetails } from "./IpDetails";
|
||||
|
||||
interface Props {
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ msg }: { msg: string }) {
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">{msg}</p>;
|
||||
}
|
||||
|
||||
function severityStyle(sev: string | null) {
|
||||
switch ((sev || "").toLowerCase()) {
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "high": return { badge: "bg-orange-500/15 text-orange-400 ring-orange-500/30", dot: "bg-orange-400" };
|
||||
case "warning": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "info": return { badge: "bg-blue-500/15 text-blue-400 ring-blue-500/30", dot: "bg-blue-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentSecurityTab({ data }: Props) {
|
||||
const security = data?.security;
|
||||
if (!security) return <EmptyState msg="No security data available." />;
|
||||
|
||||
const INSECURE_COLS: ColDef[] = [
|
||||
{ header: "IP", width: "min-w-[100px]" },
|
||||
{ header: "Protocol", width: "min-w-[80px]" },
|
||||
{ header: "Risk", width: "min-w-[100px]" },
|
||||
{ header: "App", width: "min-w-[120px] w-full" },
|
||||
{ header: "Dst IP:Port", width: "min-w-[140px]" },
|
||||
{ header: "Download", width: "w-[80px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[80px]", align: "right" },
|
||||
];
|
||||
|
||||
const REPUTATION_COLS: ColDef[] = [
|
||||
{ header: "Remote IP", width: "min-w-[120px]" },
|
||||
{ header: "Local IP", width: "min-w-[120px]" },
|
||||
{ header: "Reputation", width: "min-w-[140px] w-full" },
|
||||
{ header: "Score", width: "w-[80px]", align: "center" },
|
||||
{ header: "Country", width: "w-[80px]" },
|
||||
{ header: "Status", width: "w-[100px]" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Insecure Protocols */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<UnlockIcon className="w-4 h-4 text-orange-400" /> Insecure Protocols
|
||||
<span className="ml-auto text-xs text-muted-foreground">{security.insecure_protocols?.length ?? 0} records</span>
|
||||
</h4>
|
||||
{(security.insecure_protocols?.length ?? 0) === 0 ? <EmptyState msg="No insecure protocols detected for this agent." /> : (
|
||||
<TableWrap cols={INSECURE_COLS}>
|
||||
{security.insecure_protocols.map((r: any, i: number) => {
|
||||
const sc = severityStyle(r.risk);
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-blue-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.protocol || "—"} className="text-slate-200" /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] uppercase font-semibold tracking-wide ring-1 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{r.risk || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.app_label || "—"} className="text-purple-300" /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 truncate min-w-0">
|
||||
<div className="font-mono text-muted-foreground truncate" title={r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}>
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</div>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(r.download)} className="text-cyan-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(r.upload)} className="text-green-400 font-mono" right /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* IP Reputation */}
|
||||
{(security.ip_reputation?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Eye className="w-4 h-4 text-purple-400" /> IP Reputation
|
||||
<span className="ml-auto text-xs text-muted-foreground">{security.ip_reputation.length} records</span>
|
||||
</h4>
|
||||
<TableWrap cols={REPUTATION_COLS}>
|
||||
{security.ip_reputation.map((r: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-red-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.local_ip || "—"} className="font-mono text-blue-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.reputation || "—"} className="text-slate-300" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.score ?? "—"} className="text-yellow-400 font-mono" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.country || "—"} className="text-muted-foreground" /></td>
|
||||
<td className="px-3 py-2.5 text-xs">
|
||||
{r.blacklisted ? <span className="text-red-400 font-semibold">Blocked</span> : <span className="text-green-400">Allowed</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnlockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// src/components/ui/AgentTelemetryTab.tsx
|
||||
"use client";
|
||||
|
||||
import { useTimeline } from "@/lib/api-with-context";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from "recharts";
|
||||
|
||||
interface Props {
|
||||
agentUuid: string;
|
||||
}
|
||||
|
||||
export default function AgentTelemetryTab({ agentUuid }: Props) {
|
||||
const { data, isLoading } = useTimeline(60, agentUuid);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-muted-foreground text-sm">
|
||||
No telemetry data recorded for this agent in the last 24 hours.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format time labels for XAxis
|
||||
const chartData = data.map(pt => {
|
||||
const date = new Date(pt.fetched_at);
|
||||
const dateStr = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||||
const timeStr = date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
const xAxisLabel = `${dateStr} ${timeStr}`;
|
||||
|
||||
const rawFullLabel = date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const fullLabel = rawFullLabel.replace(' at ', ', ') + ' WIB';
|
||||
|
||||
return {
|
||||
...pt,
|
||||
xAxisLabel,
|
||||
fullLabel,
|
||||
flows: pt.total_flows ?? 0,
|
||||
peak: pt.peak_flow_rate ?? 0,
|
||||
drops: pt.packet_drops ?? 0,
|
||||
cpu: pt.cpu_usage ?? 0,
|
||||
memory: pt.memory_usage ?? 0,
|
||||
queue: pt.queue_depth ?? 0
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-4">
|
||||
{/* Flow Rates Chart */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-blue-400">Flow Rates & Telemetry Peak</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="flowsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="peakGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="flows" name="Active Flows" stroke="#3b82f6" fillOpacity={1} fill="url(#flowsGrad)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="peak" name="Peak Flow Rate" stroke="#8b5cf6" fillOpacity={1} fill="url(#peakGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packet Drops Chart */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-rose-400">Packet Drop Rate Trend</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="dropsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="drops" name="Packet Drops (pkts/sec)" stroke="#f43f5e" fillOpacity={1} fill="url(#dropsGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Resource Telemetry (CPU & Memory) */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-emerald-400">Agent Resource Telemetry (CPU & RAM)</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="memGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} unit="%" />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="cpu" name="CPU Usage (%)" stroke="#10b981" fillOpacity={1} fill="url(#cpuGrad)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="memory" name="Memory Usage (%)" stroke="#f59e0b" fillOpacity={1} fill="url(#memGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Network Queue Depth */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-cyan-400">Network Packet Queue Depth Trend</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="queueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="queue" name="Queue Depth (packets)" stroke="#06b6d4" fillOpacity={1} fill="url(#queueGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +1,219 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchAppDetails, AppDetails } from "@/lib/api";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, X, Globe, Server, Monitor } from "lucide-react";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import ActiveDurationDisplay from "./ActiveDurationDisplay";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
appLabel: string;
|
||||
favicon?: string | null;
|
||||
category?: string | null;
|
||||
agentUuid?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AppDetailModal({ appLabel, favicon, category, onClose }: Props) {
|
||||
export function AppDetailModal({ appLabel, favicon, category, agentUuid, onClose }: Props) {
|
||||
const [data, setData] = useState<AppDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
const { timeRange } = useTimeFilter();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 480);
|
||||
setTimeout(() => { onClose(); }, 480);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
fetchAppDetails(appLabel, timeRange)
|
||||
setPage(1); // Reset page on query updates
|
||||
fetchAppDetails(appLabel, timeRange, agentUuid)
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [appLabel, timeRange]);
|
||||
}, [appLabel, timeRange, agentUuid]);
|
||||
|
||||
const topIps = data?.top_ips || [];
|
||||
const totalPages = Math.ceil(topIps.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginatedIps = topIps.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-2xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-green-900/30 to-emerald-900/20 rounded-t-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg ring-1 ring-green-500/30">
|
||||
{favicon ? (
|
||||
<img src={favicon} alt={appLabel} className="w-5 h-5 rounded" />
|
||||
) : (
|
||||
<Globe className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-card-foreground">{appLabel}</h3>
|
||||
{category && <p className="text-xs text-muted-foreground">{category}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all flex-shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-400" />
|
||||
<p className="text-muted-foreground text-sm">Loading application data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
) : data ? (
|
||||
<div className="p-6 space-y-6">
|
||||
|
||||
{/* Total Summary */}
|
||||
<div className="grid grid-cols-2 gap-3 p-4 bg-white/[0.03] border border-border/50 rounded-xl">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Total Download</p>
|
||||
<p className="text-xl font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Total Upload</p>
|
||||
<p className="text-xl font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Devices accessing this app */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-blue-400" />
|
||||
Devices Accessing {appLabel}
|
||||
</h4>
|
||||
{(!data.devices || data.devices.length === 0) ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">No active devices found for this app in the selected time range.</p>
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-3xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-green-900/30 to-emerald-900/20 rounded-t-2xl flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg ring-1 ring-green-500/30">
|
||||
{favicon ? (
|
||||
<img src={favicon} alt={appLabel} className="w-5 h-5 rounded" />
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-border/50 bg-white/[0.02]">
|
||||
<table className="w-full text-sm text-left whitespace-nowrap">
|
||||
<thead className="bg-secondary/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Timestamp</th>
|
||||
<th className="px-4 py-3 font-medium">Agent</th>
|
||||
<th className="px-4 py-3 font-medium">IP Address</th>
|
||||
<th className="px-4 py-3 font-medium">Hostname</th>
|
||||
<th className="px-4 py-3 font-medium">OS</th>
|
||||
<th className="px-4 py-3 font-medium">MAC Address</th>
|
||||
<th className="px-4 py-3 font-medium">Download</th>
|
||||
<th className="px-4 py-3 font-medium">Upload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{data.devices.map((dev: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-4 py-3 text-muted-foreground">{new Date(dev.timestamp).toLocaleString()}</td>
|
||||
<td className="px-4 py-3">{dev.agent_uuid || 'Global'}</td>
|
||||
<td className="px-4 py-3 font-mono text-blue-300">{dev.ip || '-'}</td>
|
||||
<td className="px-4 py-3">{dev.hostname || '-'}</td>
|
||||
<td className="px-4 py-3">{dev.os || '-'}</td>
|
||||
<td className="px-4 py-3 font-mono text-muted-foreground">{dev.mac || '-'}</td>
|
||||
<td className="px-4 py-3 text-cyan-400">{fmtBytes(dev.download)}</td>
|
||||
<td className="px-4 py-3 text-green-400">{fmtBytes(dev.upload)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Globe className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-card-foreground">{appLabel}</h3>
|
||||
{category && <p className="text-xs text-muted-foreground">{category}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all flex-shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-400" />
|
||||
<p className="text-muted-foreground text-sm">Loading application data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
) : data ? (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Total Summary */}
|
||||
<div className="grid grid-cols-2 gap-3 p-4 bg-white/[0.03] border border-border/50 rounded-xl">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Total Download</p>
|
||||
<p className="text-xl font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Total Upload</p>
|
||||
<p className="text-xl font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Devices list */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-blue-400" />
|
||||
Devices Accessing {appLabel}
|
||||
</h4>
|
||||
{topIps.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">No active devices found for this app in the selected time range.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<TableWrap cols={[
|
||||
{ header: "Status / Last Seen", width: "w-[150px]", align: "center" },
|
||||
{ header: "IP Address", width: "min-w-[120px]" },
|
||||
{ header: "Domain", width: "min-w-[160px] w-full" },
|
||||
{ header: "Protocol", width: "w-[80px]", align: "center" },
|
||||
{ header: "Download", width: "w-[100px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[100px]", align: "right" }
|
||||
]}>
|
||||
{paginatedIps.map((dev: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<ActiveDurationDisplay firstSeen={dev.first_seen} lastSeen={dev.last_seen} className="border-0 bg-transparent p-0 mb-0 flex justify-center" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<button
|
||||
onClick={() => setSelectedIp(dev.ip_address || dev.dst_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none truncate text-left"
|
||||
title={dev.ip_address || dev.dst_ip || '-'}
|
||||
>
|
||||
{dev.ip_address || dev.dst_ip || '-'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={dev.domain || '-'} className="text-slate-300" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={dev.protocol || '-'} className="text-slate-400 font-mono uppercase text-[10px]" center />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={fmtBytes(dev.download)} className="text-cyan-400 font-mono" right />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={fmtBytes(dev.upload)} className="text-green-400 font-mono" right />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={topIps.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedIp && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedIp}
|
||||
onClose={() => setSelectedIp(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Search, Download } from "lucide-react";
|
||||
|
||||
export interface Column<T> {
|
||||
header: React.ReactNode;
|
||||
@@ -9,6 +9,12 @@ export interface Column<T> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface CsvExportOptions<T> {
|
||||
filename: string;
|
||||
headers: string[];
|
||||
rowSerializer: (row: T) => (string | number | null | undefined)[];
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
@@ -17,6 +23,28 @@ interface DataTableProps<T> {
|
||||
isLoading?: boolean;
|
||||
getRowClassName?: (row: T) => string;
|
||||
pageSize?: number;
|
||||
csvExport?: CsvExportOptions<T>;
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
function escapeCsvCell(val: string | number | null | undefined): string {
|
||||
const str = val == null ? "" : String(val);
|
||||
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, rows: (string | number | null | undefined)[][]): void {
|
||||
const bom = "\uFEFF";
|
||||
const csv = bom + rows.map(row => row.map(escapeCsvCell).join(",")).join("\r\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
@@ -26,7 +54,9 @@ export function DataTable<T>({
|
||||
searchFilter,
|
||||
isLoading,
|
||||
getRowClassName,
|
||||
pageSize = 50
|
||||
pageSize = 50,
|
||||
csvExport,
|
||||
onRowClick,
|
||||
}: DataTableProps<T>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -39,30 +69,51 @@ export function DataTable<T>({
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const handleCsvExport = () => {
|
||||
if (!csvExport) return;
|
||||
const rows = [
|
||||
csvExport.headers,
|
||||
...filteredData.map(row => csvExport.rowSerializer(row)),
|
||||
];
|
||||
downloadCsv(csvExport.filename, rows);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{searchFilter && (
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl border border-border overflow-hidden bg-card/50 backdrop-blur-xl shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
{searchFilter && (
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{csvExport && (
|
||||
<button
|
||||
onClick={handleCsvExport}
|
||||
title={`Export ${filteredData.length} rows to CSV`}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/70 hover:text-emerald-300 transition-all shadow-sm whitespace-nowrap"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export CSV ({filteredData.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/50 backdrop-blur-xl shadow-sm overflow-visible">
|
||||
<div className="w-full overflow-visible">
|
||||
<table className="w-full text-sm text-left table-fixed">
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50">
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50 relative z-20">
|
||||
<tr>
|
||||
{columns.map((col, i) => (
|
||||
<th key={i} className={`px-2 py-3.5 font-medium tracking-wide text-center ${col.className || ""}`}>
|
||||
<th key={i} className={`px-4 py-3.5 h-[48px] font-medium tracking-wide whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
@@ -98,10 +149,11 @@ export function DataTable<T>({
|
||||
paginatedData.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={`h-[64px] hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`hover:bg-primary/5 border-b border-border/50 transition-colors duration-200 group h-[48px] ${onRowClick ? "cursor-pointer hover:bg-primary/10" : ""} ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} className={`px-2 py-3 text-center text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
<td key={j} className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground group-hover:text-white transition-colors truncate whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
</td>
|
||||
))}
|
||||
@@ -112,7 +164,6 @@ export function DataTable<T>({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredData.length > pageSize && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
apps: any[];
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function DeviceAppsTab({ apps, totalDownload, totalUpload }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(apps.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = apps.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Box className="w-12 h-12 text-muted-foreground/60 mx-auto mb-3" />
|
||||
<h4 className="text-card-foreground font-bold text-lg mb-1">No Application Traffic</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
No application-specific traffic has been classified for this IP yet, or data is currently being ingested from the network sensor.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((app: any, i: number) => {
|
||||
const appBytes = app.download + app.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-slate-50/50 dark:bg-white/[0.03] border border-border p-4 rounded-2xl hover:bg-slate-100/50 dark:hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-blue-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-card-foreground mb-3 text-sm flex justify-between items-center">
|
||||
{app.app_label}
|
||||
<span className="text-[10px] font-mono text-blue-600 dark:text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full ring-1 ring-blue-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-medium">{fmtBytes(app.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(app.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={apps.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,150 +2,32 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchDeviceDetails, DeviceDetails, DeviceStat } from "@/lib/api";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, X, Monitor, Activity, Shield, Box, Server, Cpu, Clock, HardDrive, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Globe, Link2 } from "lucide-react";
|
||||
import ActiveDurationDisplay from "./ActiveDurationDisplay";
|
||||
|
||||
const PORT_ALIASES: Record<string, string> = {
|
||||
"20": "FTP Data",
|
||||
"21": "FTP Control",
|
||||
"22": "SSH",
|
||||
"23": "Telnet",
|
||||
"25": "SMTP",
|
||||
"53": "DNS",
|
||||
"67": "DHCP Server",
|
||||
"68": "DHCP Client",
|
||||
"80": "HTTP",
|
||||
"110": "POP3",
|
||||
"123": "NTP",
|
||||
"143": "IMAP",
|
||||
"161": "SNMP",
|
||||
"443": "HTTPS",
|
||||
"445": "SMB",
|
||||
"500": "IKE/IPsec",
|
||||
"1433": "MS SQL Server",
|
||||
"1521": "Oracle DB",
|
||||
"3306": "MySQL",
|
||||
"3389": "RDP",
|
||||
"5432": "PostgreSQL",
|
||||
"6379": "Redis",
|
||||
"8080": "HTTP Alternate",
|
||||
"8443": "HTTPS Alternate",
|
||||
"5222": "XMPP/Jabber"
|
||||
};
|
||||
|
||||
function formatProtocolLabel(label: string) {
|
||||
if (label.startsWith("Port ")) {
|
||||
const portNum = label.split(" ")[1];
|
||||
|
||||
// Check known aliases
|
||||
if (PORT_ALIASES[portNum]) {
|
||||
return `Port ${portNum} (${PORT_ALIASES[portNum]})`;
|
||||
}
|
||||
|
||||
// Check ephemeral range (49152 to 65535)
|
||||
const portInt = parseInt(portNum, 10);
|
||||
if (!isNaN(portInt)) {
|
||||
if (portInt >= 49152 && portInt <= 65535) {
|
||||
return `Port ${portNum} (Ephemeral/Dynamic)`;
|
||||
} else {
|
||||
return `Port ${portNum} (Unassigned/Other)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function formatActiveDuration(firstSeen?: string, lastSeen?: string) {
|
||||
if (!firstSeen || !lastSeen) return null;
|
||||
const start = new Date(firstSeen).getTime();
|
||||
const end = new Date(lastSeen).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
const dateObj = new Date(lastSeen);
|
||||
const fullTimestamp = dateObj.toLocaleString("id-ID", {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
|
||||
let diffSec = Math.floor((end - start) / 1000);
|
||||
if (diffSec < 0) diffSec = 0;
|
||||
|
||||
const h = Math.floor(diffSec / 3600);
|
||||
const m = Math.floor((diffSec % 3600) / 60);
|
||||
const s = diffSec % 60;
|
||||
|
||||
let durationText = "Just now";
|
||||
if (h > 0) durationText = `${h}h ${m}m ${s}s`;
|
||||
else if (m > 0) durationText = `${m}m ${s}s`;
|
||||
else if (s > 0) durationText = `${s}s`;
|
||||
|
||||
// If last seen was within 5 minutes (300 seconds), consider it Active
|
||||
const timeSinceLastSeen = Math.floor((now - end) / 1000);
|
||||
const isActive = timeSinceLastSeen <= 300;
|
||||
|
||||
let lastSeenText = "";
|
||||
if (!isActive) {
|
||||
const agoH = Math.floor(timeSinceLastSeen / 3600);
|
||||
const agoM = Math.floor((timeSinceLastSeen % 3600) / 60);
|
||||
if (agoH > 24) {
|
||||
const agoD = Math.floor(agoH / 24);
|
||||
lastSeenText = `${agoD}d ago`;
|
||||
} else if (agoH > 0) {
|
||||
lastSeenText = `${agoH}h ago`;
|
||||
} else if (agoM > 0) {
|
||||
lastSeenText = `${agoM}m ago`;
|
||||
} else {
|
||||
lastSeenText = "a few seconds ago";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
durationText,
|
||||
fullTimestamp,
|
||||
lastSeenText
|
||||
};
|
||||
}
|
||||
|
||||
function ActiveDurationDisplay({ firstSeen, lastSeen }: { firstSeen?: string, lastSeen?: string }) {
|
||||
if (!firstSeen || !lastSeen) return null;
|
||||
const dur = formatActiveDuration(firstSeen, lastSeen);
|
||||
if (!dur) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 bg-black/20 p-2.5 rounded-lg border border-white/5 mb-3">
|
||||
<div className="flex justify-between text-xs items-center">
|
||||
<span className={`flex items-center gap-1.5 ${dur.isActive ? 'text-blue-400' : 'text-slate-400'}`}>
|
||||
<Clock className="w-3.5 h-3.5" /> {dur.isActive ? "Active" : "Last seen"}
|
||||
</span>
|
||||
<span className="text-slate-300 font-medium">
|
||||
{dur.isActive ? dur.durationText : dur.lastSeenText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 text-right">
|
||||
{dur.fullTimestamp}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import DeviceAppsTab from "./DeviceAppsTab";
|
||||
import DeviceProtocolsTab from "./DeviceProtocolsTab";
|
||||
import DeviceDomainsTab from "./DeviceDomainsTab";
|
||||
import DeviceFlowsTab from "./DeviceFlowsTab";
|
||||
import DeviceThreatsTab from "./DeviceThreatsTab";
|
||||
|
||||
interface Props {
|
||||
ip: string;
|
||||
mac?: string;
|
||||
deviceData?: DeviceStat;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "info" | "apps" | "protocols" | "domains" | "flows" | "threats";
|
||||
|
||||
export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props) {
|
||||
const [data, setData] = useState<DeviceDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("info");
|
||||
const { timeRange } = useTimeFilter();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
@@ -155,10 +37,10 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
const loadData = async (silent = false) => {
|
||||
if (!silent) setIsLoading(true);
|
||||
else setIsRefreshing(true);
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchDeviceDetails(ip, timeRange);
|
||||
const agentUuid = deviceData?.agent_uuid || '';
|
||||
const res = await fetchDeviceDetails(ip, '1d', mac, agentUuid);
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
if (!silent) setError(e.message);
|
||||
@@ -169,13 +51,8 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// Real-time polling every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadData(true);
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [ip, timeRange]);
|
||||
loadData(false);
|
||||
}, [ip]);
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "info", label: "Overview", icon: Monitor },
|
||||
@@ -190,47 +67,49 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/60 backdrop-blur-md ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-slate-950/80 border border-slate-800/60 rounded-3xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-blue-900/20 overflow-hidden backdrop-blur-xl ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
className={`relative bg-card border border-border rounded-3xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/10 dark:shadow-blue-900/20 overflow-hidden backdrop-blur-xl ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Decorative Top Glow */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-gradient-to-br from-blue-900/10 via-slate-900/20 to-slate-950/40 relative z-10">
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-border bg-gradient-to-br from-blue-500/5 via-slate-500/5 to-slate-500/10 dark:from-blue-900/10 dark:via-slate-900/20 dark:to-slate-950/40 relative z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-blue-500 blur-xl opacity-20 rounded-full" />
|
||||
<div className="p-3 bg-gradient-to-br from-blue-500/20 to-cyan-500/10 rounded-xl ring-1 ring-blue-500/30 relative">
|
||||
<Monitor className="w-6 h-6 text-blue-400" />
|
||||
<Monitor className="w-6 h-6 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-white tracking-tight">{ip}</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-blue-400 animate-spin" />}
|
||||
<h3 className="text-xl font-bold text-card-foreground tracking-tight">{ip}</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 animate-spin" />}
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-0.5">
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{data?.device_label || deviceData?.device_label || 'Unknown Device'}
|
||||
<span className="text-slate-600 mx-2">•</span>
|
||||
<span className="text-border mx-2">•</span>
|
||||
<span className="font-mono text-xs">{data?.mac_address || deviceData?.mac_address || 'No MAC'}</span>
|
||||
</p>
|
||||
{data?.agent_label && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1 flex items-center gap-1">
|
||||
<Server className="w-3.5 h-3.5" />
|
||||
Source Agent: <strong>{data.agent_label}</strong> <span className="text-muted-foreground/60">({data.agent_uuid})</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<button onClick={handleClose} className="p-2.5 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col flex-1 min-h-0 bg-gradient-to-b from-transparent to-slate-950/50 relative z-10">
|
||||
<div className="flex flex-col flex-1 min-h-0 bg-gradient-to-b from-transparent to-muted/20 relative z-10">
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-blue-500" />
|
||||
<p className="text-slate-400 font-medium">Gathering device telemetry...</p>
|
||||
<p className="text-muted-foreground font-medium">Gathering device telemetry...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6 text-center">
|
||||
@@ -243,7 +122,7 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-white/5 overflow-x-auto custom-scrollbar flex-shrink-0 bg-black/20">
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-border overflow-x-hidden custom-scrollbar flex-shrink-0 bg-secondary/30 flex-wrap">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const isActive = activeTab === t.id;
|
||||
@@ -253,11 +132,11 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`flex items-center gap-2.5 px-4 py-2 rounded-xl text-sm font-semibold transition-all duration-300 ${
|
||||
isActive
|
||||
? "bg-gradient-to-r from-blue-500/20 to-cyan-500/10 text-blue-300 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.15)]"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/5 border border-transparent"
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-300 border border-blue-500/30"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${isActive ? 'text-blue-400' : ''}`} />
|
||||
<Icon className={`w-4 h-4 ${isActive ? 'text-blue-600 dark:text-blue-400' : ''}`} />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
@@ -266,337 +145,75 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
|
||||
{/* Info Tab */}
|
||||
{activeTab === "info" && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Traffic Summary */}
|
||||
<div className="space-y-6 animate-fade-in text-xs">
|
||||
<ActiveDurationDisplay firstSeen={data.first_seen || undefined} lastSeen={data.last_seen || undefined} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-6 bg-gradient-to-br from-cyan-500/10 to-transparent border border-cyan-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="p-6 bg-gradient-to-br from-cyan-500/5 dark:from-cyan-500/10 to-transparent border border-cyan-100 dark:border-cyan-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowDownToLine className="w-16 h-16 text-cyan-400" />
|
||||
<ArrowDownToLine className="w-16 h-16 text-cyan-500 dark:text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4" /> Total Download
|
||||
</p>
|
||||
<p className="text-4xl font-black text-white tracking-tight">{fmtBytes(data.total_download)}</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-br from-emerald-500/10 to-transparent border border-emerald-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="p-6 bg-gradient-to-br from-emerald-500/5 dark:from-emerald-500/10 to-transparent border border-emerald-100 dark:border-emerald-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowUpFromLine className="w-16 h-16 text-emerald-400" />
|
||||
<ArrowUpFromLine className="w-16 h-16 text-emerald-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowUpFromLine className="w-4 h-4" /> Total Upload
|
||||
</p>
|
||||
<p className="text-4xl font-black text-white tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Metadata */}
|
||||
<div className="bg-white/[0.02] border border-white/5 rounded-2xl p-6">
|
||||
<h4 className="text-sm font-bold text-slate-300 uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-400" /> Identity & Metadata
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6">
|
||||
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" /> Identity & Metadata
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 flex items-center gap-1.5 mb-1.5">
|
||||
<Cpu className="w-3.5 h-3.5" /> OS
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Cpu className="w-3.5 h-3.5" /> OS</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 flex items-center gap-1.5 mb-1.5">
|
||||
<Monitor className="w-3.5 h-3.5" /> Type
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Monitor className="w-3.5 h-3.5" /> Type</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 flex items-center gap-1.5 mb-1.5">
|
||||
<HardDrive className="w-3.5 h-3.5" /> Manufacturer
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><HardDrive className="w-3.5 h-3.5" /> Manufacturer</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 flex items-center gap-1.5 mb-1.5">
|
||||
<Clock className="w-3.5 h-3.5" /> Last Seen
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Clock className="w-3.5 h-3.5" /> Last Seen</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Activity / Destinations */}
|
||||
<div className="bg-black/40 border border-white/5 rounded-2xl p-6">
|
||||
<h4 className="text-xs font-bold tracking-widest text-blue-400/80 uppercase flex items-center gap-2 mb-4">
|
||||
<Globe className="w-4 h-4" /> Current Activity (Top Destinations)
|
||||
</h4>
|
||||
{(!data.destinations || data.destinations.length === 0) ? (
|
||||
<p className="text-slate-400 text-sm italic">No active destination data available.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.destinations.slice(0, 5).map((dst: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 rounded-xl bg-white/[0.02] border border-white/5 hover:bg-white/[0.04] transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center border border-blue-500/20">
|
||||
<Activity className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-200 font-mono text-sm">{dst.app_label}</p>
|
||||
<p className="text-slate-500 text-xs">Destination IP</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-right">
|
||||
<div>
|
||||
<p className="text-cyan-400 text-xs font-medium">{fmtBytes(dst.download)}</p>
|
||||
<p className="text-slate-500 text-[10px] uppercase tracking-widest">Down</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-emerald-400 text-xs font-medium">{fmtBytes(dst.upload)}</p>
|
||||
<p className="text-slate-500 text-[10px] uppercase tracking-widest">Up</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apps Tab */}
|
||||
{activeTab === "apps" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.apps || data.apps.length === 0) ? (
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-white/5 rounded-2xl">
|
||||
<Box className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<h4 className="text-slate-300 font-bold text-lg mb-1">No Application Traffic</h4>
|
||||
<p className="text-slate-400 font-medium max-w-md mx-auto">
|
||||
No application-specific traffic has been classified for this IP yet, or data is currently being ingested from the network sensor.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.apps.map((app: any, i: number) => {
|
||||
const totalBytes = data.total_download + data.total_upload;
|
||||
const appBytes = app.download + app.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-white/[0.03] border border-white/5 p-4 rounded-2xl hover:bg-white/[0.05] transition-colors group relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 h-1 bg-blue-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-white mb-3 text-lg flex justify-between items-center">
|
||||
{app.app_label}
|
||||
<span className="text-xs font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full ring-1 ring-blue-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<ActiveDurationDisplay firstSeen={app.first_seen} lastSeen={app.last_seen} />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Download</span>
|
||||
<span className="text-cyan-400 font-mono">{fmtBytes(app.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Upload</span>
|
||||
<span className="text-emerald-400 font-mono">{fmtBytes(app.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceAppsTab apps={data.apps} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{/* Protocols Tab */}
|
||||
{activeTab === "protocols" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.protocols || data.protocols.length === 0) ? (
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-white/5 rounded-2xl">
|
||||
<Link2 className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<h4 className="text-slate-300 font-bold text-lg mb-1">No Protocol Data</h4>
|
||||
<p className="text-slate-400 font-medium max-w-md mx-auto">
|
||||
No distinct protocols or ports have been classified for this IP yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.protocols.map((proto: any, i: number) => {
|
||||
const totalBytes = data.total_download + data.total_upload;
|
||||
const appBytes = proto.download + proto.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-white/[0.03] border border-white/5 p-4 rounded-2xl hover:bg-white/[0.05] transition-colors group relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 h-1 bg-amber-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-white mb-3 text-lg flex justify-between items-center">
|
||||
{formatProtocolLabel(proto.app_label)}
|
||||
<span className="text-xs font-mono text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full ring-1 ring-amber-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<ActiveDurationDisplay firstSeen={proto.first_seen} lastSeen={proto.last_seen} />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Download</span>
|
||||
<span className="text-cyan-400 font-mono">{fmtBytes(proto.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Upload</span>
|
||||
<span className="text-emerald-400 font-mono">{fmtBytes(proto.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceProtocolsTab protocols={data.protocols} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{/* Domains Tab */}
|
||||
{activeTab === "domains" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.domains || data.domains.length === 0) ? (
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-white/5 rounded-2xl">
|
||||
<Globe className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<h4 className="text-slate-300 font-bold text-lg mb-1">No Web/Domain Traffic</h4>
|
||||
<p className="text-slate-400 font-medium max-w-md mx-auto">
|
||||
No distinct domain names have been extracted from SNI or DNS data for this IP yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.domains.map((dom: any, i: number) => {
|
||||
const totalBytes = data.total_download + data.total_upload;
|
||||
const appBytes = dom.download + dom.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-white/[0.03] border border-white/5 p-4 rounded-2xl hover:bg-white/[0.05] transition-colors group relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 h-1 bg-purple-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-white mb-3 text-lg flex justify-between items-center truncate gap-2" title={dom.app_label}>
|
||||
<span className="truncate">{dom.app_label}</span>
|
||||
<span className="text-xs font-mono text-purple-400 bg-purple-500/10 px-2 py-0.5 rounded-full ring-1 ring-purple-500/20 shrink-0">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<ActiveDurationDisplay firstSeen={dom.first_seen} lastSeen={dom.last_seen} />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Download</span>
|
||||
<span className="text-cyan-400 font-mono">{fmtBytes(dom.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Upload</span>
|
||||
<span className="text-emerald-400 font-mono">{fmtBytes(dom.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceDomainsTab domains={data.domains} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{/* Flows Tab */}
|
||||
{activeTab === "flows" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.flows || data.flows.length === 0) ? (
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-white/5 rounded-2xl">
|
||||
<Activity className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<h4 className="text-slate-300 font-bold text-lg mb-1">No Flow Data Yet</h4>
|
||||
<p className="text-slate-400 font-medium max-w-md mx-auto">
|
||||
Network flows for this IP haven't been captured or are currently being ingested. If you just started the system, please wait a few moments as data is actively being synchronized.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto custom-scrollbar max-h-[60vh]">
|
||||
<table className="w-full text-sm text-left whitespace-nowrap">
|
||||
<thead className="bg-slate-900/80 text-slate-300 sticky top-0 backdrop-blur-md border-b border-white/10 z-10">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-semibold">Time</th>
|
||||
<th className="px-5 py-4 font-semibold">Source</th>
|
||||
<th className="px-5 py-4 font-semibold">Destination</th>
|
||||
<th className="px-5 py-4 font-semibold">Port</th>
|
||||
<th className="px-5 py-4 font-semibold">App / Protocol</th>
|
||||
<th className="px-5 py-4 font-semibold text-right">Download</th>
|
||||
<th className="px-5 py-4 font-semibold text-right">Upload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{data.flows.map((f: any, i: number) => {
|
||||
const isOutbound = f.src_ip === ip;
|
||||
return (
|
||||
<tr key={i} className="hover:bg-blue-500/5 transition-colors">
|
||||
<td className="px-5 py-3 text-slate-400 text-xs">{new Date(f.timestamp).toLocaleString()}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`font-mono ${isOutbound ? 'text-blue-300 font-semibold' : 'text-slate-400'}`}>{f.src_ip}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`font-mono ${!isOutbound ? 'text-blue-300 font-semibold' : 'text-slate-400'}`}>{f.dst_ip}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-amber-200/80 font-mono text-xs">{f.dst_port || '-'}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className="bg-white/10 text-slate-200 px-2.5 py-1 rounded-md text-xs font-medium border border-white/5">
|
||||
{f.app_label || f.protocol || "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-cyan-400 font-mono text-xs">{fmtBytes(f.download)}</td>
|
||||
<td className="px-5 py-3 text-right text-emerald-400 font-mono text-xs">{fmtBytes(f.upload)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceFlowsTab flows={data.flows} ip={ip} />
|
||||
)}
|
||||
|
||||
{/* Threats Tab */}
|
||||
{activeTab === "threats" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.threats || data.threats.length === 0) ? (
|
||||
<div className="text-center py-12 bg-emerald-500/5 border border-emerald-500/20 rounded-2xl">
|
||||
<Shield className="w-14 h-14 text-emerald-400 mx-auto mb-4" />
|
||||
<h4 className="text-emerald-400 font-bold text-lg mb-1">No Threats Detected</h4>
|
||||
<p className="text-emerald-300/70 text-sm max-w-md mx-auto">
|
||||
This device's traffic appears secure. Keep in mind that new threat intelligence data may take a few moments to sync after a system restart.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 overflow-hidden">
|
||||
<table className="w-full text-sm text-left whitespace-nowrap">
|
||||
<thead className="bg-slate-900/80 text-slate-300 sticky top-0 backdrop-blur-md border-b border-white/10 z-10">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-semibold">Time</th>
|
||||
<th className="px-5 py-4 font-semibold">Type</th>
|
||||
<th className="px-5 py-4 font-semibold">Severity</th>
|
||||
<th className="px-5 py-4 font-semibold">Target IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{data.threats.map((t: any, i: number) => {
|
||||
const sev = (t.severity || '').toLowerCase();
|
||||
let badgeCls = "bg-slate-500/20 text-slate-300 ring-slate-500/30";
|
||||
if (sev === 'high' || sev === 'critical') badgeCls = "bg-red-500/20 text-red-400 ring-red-500/30";
|
||||
else if (sev === 'medium') badgeCls = "bg-amber-500/20 text-amber-400 ring-amber-500/30";
|
||||
else if (sev === 'low') badgeCls = "bg-blue-500/20 text-blue-400 ring-blue-500/30";
|
||||
|
||||
return (
|
||||
<tr key={i} className="hover:bg-red-500/5 transition-colors">
|
||||
<td className="px-5 py-3 text-slate-400 text-xs">{new Date(t.timestamp).toLocaleString()}</td>
|
||||
<td className="px-5 py-3 font-semibold text-slate-200">{t.threat_type || "-"}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wider ring-1 ${badgeCls}`}>
|
||||
{t.severity || "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 font-mono text-slate-300">{t.dst_ip || "-"}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceThreatsTab threats={data.threats} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
domains: any[];
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function DeviceDomainsTab({ domains, totalDownload, totalUpload }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(domains.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = domains.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Globe className="w-12 h-12 text-muted-foreground/60 mx-auto mb-3" />
|
||||
<h4 className="text-card-foreground font-bold text-lg mb-1">No Web/Domain Traffic</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
No distinct domain names have been extracted from SNI or DNS data for this IP yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((dom: any, i: number) => {
|
||||
const appBytes = dom.download + dom.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-slate-50/50 dark:bg-white/[0.03] border border-border p-4 rounded-2xl hover:bg-slate-100/50 dark:hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-purple-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-card-foreground mb-3 text-sm flex justify-between items-center truncate gap-2" title={dom.app_label}>
|
||||
<span className="truncate">{dom.app_label}</span>
|
||||
<span className="text-[10px] font-mono text-purple-600 dark:text-purple-400 bg-purple-500/10 px-2 py-0.5 rounded-full ring-1 ring-purple-500/20 shrink-0">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-medium">{fmtBytes(dom.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(dom.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={domains.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
flows: any[];
|
||||
ip: string;
|
||||
}
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[60vh] rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[800px]">
|
||||
<thead className="sticky top-0 bg-[#111827] z-10">
|
||||
<tr className="border-b border-border">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceFlowsTab({ flows, ip }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(flows.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = flows.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
if (flows.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Activity className="w-12 h-12 text-muted-foreground/60 mx-auto mb-3" />
|
||||
<h4 className="text-card-foreground font-bold text-lg mb-1">No Flow Data Yet</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
Network flows for this IP haven't been captured or are currently being ingested. If you just started the system, please wait a few moments as data is actively being synchronized.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "Time", width: "w-[130px]", align: "center" },
|
||||
{ header: "Source", width: "min-w-[120px]" },
|
||||
{ header: "Destination", width: "min-w-[120px]" },
|
||||
{ header: "Port", width: "w-[80px]", align: "center" },
|
||||
{ header: "App / Protocol", width: "min-w-[150px] w-full" },
|
||||
{ header: "Download", width: "w-[90px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[90px]", align: "right" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TableWrap cols={COLS}>
|
||||
{paginated.map((f: any, i: number) => {
|
||||
const isOutbound = f.src_ip === ip;
|
||||
const timeStr = f.last_seen ? new Date(f.last_seen).toLocaleString() : '-';
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={timeStr} className="text-muted-foreground" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={f.src_ip} className={`font-mono ${isOutbound ? 'text-blue-400 font-semibold' : 'text-slate-400'}`} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={f.dst_ip} className={`font-mono ${!isOutbound ? 'text-blue-400 font-semibold' : 'text-slate-400'}`} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={f.dst_port || '-'} className="text-amber-400/80 font-mono" center />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className="bg-white/10 text-slate-200 px-2 py-0.5 rounded text-[10px] font-medium border border-white/5 truncate max-w-full inline-block align-middle" title={f.app_label || f.protocol || '-'}>
|
||||
{f.app_label || f.protocol || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" right /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
{totalPages > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={flows.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
const PORT_ALIASES: Record<string, string> = {
|
||||
"20": "FTP Data", "21": "FTP Control", "22": "SSH", "23": "Telnet",
|
||||
"25": "SMTP", "53": "DNS", "67": "DHCP Server", "68": "DHCP Client",
|
||||
"80": "HTTP", "110": "POP3", "123": "NTP", "143": "IMAP", "161": "SNMP",
|
||||
"443": "HTTPS", "445": "SMB", "500": "IKE/IPsec", "1433": "MS SQL Server",
|
||||
"1521": "Oracle DB", "3306": "MySQL", "3389": "RDP", "5432": "PostgreSQL",
|
||||
"6379": "Redis", "8080": "HTTP Alternate", "8443": "HTTPS Alternate", "5222": "XMPP/Jabber"
|
||||
};
|
||||
|
||||
function formatProtocolLabel(label: string) {
|
||||
if (label.startsWith("Port ")) {
|
||||
const portNum = label.split(" ")[1];
|
||||
if (PORT_ALIASES[portNum]) {
|
||||
return `Port ${portNum} (${PORT_ALIASES[portNum]})`;
|
||||
}
|
||||
const portInt = parseInt(portNum, 10);
|
||||
if (!isNaN(portInt)) {
|
||||
if (portInt >= 49152 && portInt <= 65535) {
|
||||
return `Port ${portNum} (Ephemeral/Dynamic)`;
|
||||
} else {
|
||||
return `Port ${portNum} (Unassigned/Other)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
protocols: any[];
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function DeviceProtocolsTab({ protocols, totalDownload, totalUpload }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(protocols.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = protocols.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
if (protocols.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Link2 className="w-12 h-12 text-muted-foreground/60 mx-auto mb-3" />
|
||||
<h4 className="text-card-foreground font-bold text-lg mb-1">No Protocol Data</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
No distinct protocols or ports have been classified for this IP yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((proto: any, i: number) => {
|
||||
const appBytes = proto.download + proto.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-slate-50/50 dark:bg-white/[0.03] border border-border p-4 rounded-2xl hover:bg-slate-100/50 dark:hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-amber-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-card-foreground mb-3 text-sm flex justify-between items-center">
|
||||
{formatProtocolLabel(proto.app_label)}
|
||||
<span className="text-[10px] font-mono text-amber-600 dark:text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full ring-1 ring-amber-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-medium">{fmtBytes(proto.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(proto.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={protocols.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Shield } from "lucide-react";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
threats: any[];
|
||||
}
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[60vh] rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[700px]">
|
||||
<thead className="sticky top-0 bg-[#111827] z-10">
|
||||
<tr className="border-b border-border">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceThreatsTab({ threats }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(threats.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = threats.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
if (threats.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 bg-emerald-500/5 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-2xl">
|
||||
<Shield className="w-14 h-14 text-emerald-600 dark:text-emerald-400 mx-auto mb-4" />
|
||||
<h4 className="text-emerald-700 dark:text-emerald-400 font-bold text-lg mb-1">No Threats Detected</h4>
|
||||
<p className="text-muted-foreground text-sm max-w-md mx-auto">
|
||||
This device's traffic appears secure. Keep in mind that new threat intelligence data may take a few moments to sync after a system restart.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "Time", width: "w-[150px]", align: "center" },
|
||||
{ header: "Type", width: "min-w-[150px] w-full" },
|
||||
{ header: "Severity", width: "w-[100px]", align: "center" },
|
||||
{ header: "Target IP", width: "min-w-[120px]" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TableWrap cols={COLS}>
|
||||
{paginated.map((t: any, i: number) => {
|
||||
const sev = (t.severity || '').toLowerCase();
|
||||
let badgeCls = "bg-slate-500/20 text-slate-300 ring-slate-500/30";
|
||||
if (sev === 'high' || sev === 'critical') badgeCls = "bg-red-500/20 text-red-400 ring-red-500/30";
|
||||
else if (sev === 'medium') badgeCls = "bg-amber-500/20 text-amber-400 ring-amber-500/30";
|
||||
else if (sev === 'low') badgeCls = "bg-blue-500/20 text-blue-400 ring-blue-500/30";
|
||||
const timeStr = t.detected_at ? new Date(t.detected_at).toLocaleString() : '-';
|
||||
return (
|
||||
<tr key={i} className="hover:bg-red-500/5 transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={timeStr} className="text-muted-foreground" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div
|
||||
title={t.description || t.threat_type || "-"}
|
||||
className="font-semibold text-card-foreground text-xs truncate max-w-full cursor-help hover:text-primary transition-colors block"
|
||||
>
|
||||
{t.threat_type || "-"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] uppercase tracking-wider font-semibold ring-1 ${badgeCls}`}>
|
||||
{t.severity || "Unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={t.dst_ip || t.ip_address || "-"} className="font-mono text-muted-foreground" /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
{totalPages > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={threats.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+40
-174
@@ -4,60 +4,14 @@ import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useTheme } from "next-themes";
|
||||
import * as THREE from 'three';
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { getArcTooltip, getPointTooltip } from './GlobeTooltips';
|
||||
import { useGlobeData, GlobeDataInput } from './useGlobeData';
|
||||
|
||||
// Dynamically import Globe to avoid SSR issues
|
||||
const Globe = dynamic(() => import('react-globe.gl'), { ssr: false });
|
||||
|
||||
const COUNTRY_COORDS: Record<string, [number, number]> = {
|
||||
"ID": [113.9213, -0.7893],
|
||||
"US": [-95.7129, 37.0902],
|
||||
"HK": [114.1095, 22.3964],
|
||||
"SG": [103.8198, 1.3521],
|
||||
"JP": [138.2529, 36.2048],
|
||||
"SC": [55.4920, -4.6796],
|
||||
"GB": [-3.4360, 55.3781],
|
||||
"IT": [12.5674, 41.8719],
|
||||
"CN": [104.1954, 35.8617],
|
||||
"KR": [127.7669, 35.9078],
|
||||
"CA": [-106.3468, 56.1304],
|
||||
"LU": [6.1296, 49.8153],
|
||||
"NL": [5.2913, 52.1326],
|
||||
"TR": [35.2433, 38.9637],
|
||||
"UA": [31.1656, 48.3794],
|
||||
"RU": [105.3188, 61.5240],
|
||||
"HU": [19.5033, 47.1625],
|
||||
"BD": [90.3563, 23.6850],
|
||||
"AU": [133.7751, -25.2744],
|
||||
"IN": [78.9629, 20.5937],
|
||||
"DE": [10.4515, 51.1657],
|
||||
"FR": [2.2137, 46.2276],
|
||||
"ES": [-3.7492, 40.4637],
|
||||
"BR": [-51.9253, -14.2350],
|
||||
"MY": [101.9758, 4.2105],
|
||||
"TH": [100.9925, 15.8700],
|
||||
"VN": [108.2772, 14.0583],
|
||||
"PH": [121.7740, 12.8797],
|
||||
"TW": [120.9605, 23.6978],
|
||||
"GR": [21.8243, 39.0742]
|
||||
};
|
||||
|
||||
// Jakarta, Indonesia as Origin
|
||||
const ORIGIN_LAT = -6.2088;
|
||||
const ORIGIN_LNG = 106.8456;
|
||||
|
||||
interface GlobeMapProps {
|
||||
data: {
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
endLat: number;
|
||||
endLng: number;
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
value: number;
|
||||
download: number;
|
||||
upload: number;
|
||||
}[];
|
||||
data: GlobeDataInput[];
|
||||
}
|
||||
|
||||
export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
@@ -116,7 +70,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
controls.addEventListener('start', () => setUserInteracting(true));
|
||||
controls.addEventListener('end', () => setUserInteracting(false));
|
||||
|
||||
// Focus on Southeast Asia initially (or dynamically from first data point if available)
|
||||
// Focus on Southeast Asia initially
|
||||
globeRef.current.pointOfView({ lat: -6.2, lng: 106.8, altitude: 1.8 }, 2000);
|
||||
}
|
||||
}, 100);
|
||||
@@ -163,90 +117,23 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [isLight, customGlobeMaterial]);
|
||||
|
||||
// Prepare Data Memos
|
||||
const arcsData = useMemo(() => {
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
|
||||
}));
|
||||
}, [data, isLight]);
|
||||
const { arcsData, pointsData, labelsData } = useGlobeData(data, isLight);
|
||||
|
||||
const pointsData = useMemo(() => {
|
||||
const points = [];
|
||||
const originSet = new Set();
|
||||
const destSet = new Set();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
points.push({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
size: 0.1,
|
||||
color: isLight ? '#0284c7' : '#38bdf8',
|
||||
name: arc.toLabel,
|
||||
value: arc.value,
|
||||
upload: arc.upload,
|
||||
download: arc.download,
|
||||
isOrigin: false
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
points.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
size: 0.2,
|
||||
color: '#f43f5e',
|
||||
name: arc.fromLabel,
|
||||
value: 0,
|
||||
upload: 0,
|
||||
download: 0,
|
||||
isOrigin: true
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
// Stable polygon color callbacks to prevent mesh rebuilds and fix drag lag (Requirement 1 & 2)
|
||||
const getPolygonCapColor = useMemo(() => {
|
||||
const color = isLight ? '#4ade80' : '#0f172a';
|
||||
return () => color;
|
||||
}, [isLight]);
|
||||
|
||||
return points;
|
||||
}, [arcsData, isLight]);
|
||||
const getPolygonSideColor = useMemo(() => {
|
||||
const color = isLight ? '#22c55e' : '#020617';
|
||||
return () => color;
|
||||
}, [isLight]);
|
||||
|
||||
// Extract All Labels + Origin to display statically
|
||||
const labelsData = useMemo(() => {
|
||||
const labels: any[] = [];
|
||||
const originSet = new Set();
|
||||
const destSet = new Set();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
labels.push({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
name: arc.toLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc'
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
labels.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
name: arc.fromLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc'
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
}, [arcsData, isLight]);
|
||||
const getPolygonStrokeColor = useMemo(() => {
|
||||
const color = isLight ? '#ffffff' : '#0ea5e9';
|
||||
return () => color;
|
||||
}, [isLight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -268,9 +155,9 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
|
||||
polygonsData={countries ? countries.features : []}
|
||||
polygonAltitude={0.015}
|
||||
polygonCapColor={() => isLight ? '#4ade80' : '#0f172a'}
|
||||
polygonSideColor={() => isLight ? '#22c55e' : '#020617'}
|
||||
polygonStrokeColor={() => isLight ? '#ffffff' : '#0ea5e9'}
|
||||
polygonCapColor={getPolygonCapColor}
|
||||
polygonSideColor={getPolygonSideColor}
|
||||
polygonStrokeColor={getPolygonStrokeColor}
|
||||
|
||||
arcsData={arcsData}
|
||||
arcStartLat={(d: any) => d.startLat}
|
||||
@@ -282,31 +169,24 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
arcDashGap={0.2}
|
||||
arcDashAnimateTime={2000}
|
||||
arcAltitude={(d: any) => {
|
||||
const latDiff = Math.abs(d.startLat - d.endLat);
|
||||
const lngDiff = Math.abs(d.startLng - d.endLng);
|
||||
const dist = Math.sqrt(latDiff*latDiff + lngDiff*lngDiff);
|
||||
return Math.max(0.1, dist * 0.005);
|
||||
// Convert lat/lng to radians
|
||||
const phi1 = d.startLat * Math.PI / 180;
|
||||
const lambda1 = d.startLng * Math.PI / 180;
|
||||
const phi2 = d.endLat * Math.PI / 180;
|
||||
const lambda2 = d.endLng * Math.PI / 180;
|
||||
|
||||
// Great-circle angular distance
|
||||
const cosTheta = Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(lambda1 - lambda2);
|
||||
const theta = Math.acos(Math.max(-1, Math.min(1, cosTheta)));
|
||||
|
||||
// Chord depth under the sphere surface
|
||||
const chordDepth = 1 - Math.cos(theta / 2);
|
||||
|
||||
// Sleeker hover height that hugs the sphere surface closely (Requirement 2)
|
||||
return chordDepth + 0.02 + (theta * 0.008);
|
||||
}}
|
||||
arcStroke={isLight ? 0.8 : 0.6}
|
||||
arcLabel={(d: any) => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: 180px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #facc15;">
|
||||
${d.fromLabel}
|
||||
</div>
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #38bdf8;">
|
||||
${d.toLabel}
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
arcLabel={(d: any) => getArcTooltip(d)}
|
||||
|
||||
pointsData={pointsData}
|
||||
pointLat="lat"
|
||||
@@ -315,22 +195,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
pointAltitude={0.01}
|
||||
pointRadius="size"
|
||||
pointsMerge={true}
|
||||
pointLabel={(d: any) => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: ${d.value > 0 ? '180px' : 'auto'}; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; ${d.value > 0 ? 'border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px;' : ''}">${d.name}</div>
|
||||
${d.value > 0 ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`}
|
||||
pointLabel={(d: any) => getPointTooltip(d)}
|
||||
|
||||
ringsData={pointsData}
|
||||
ringLat="lat"
|
||||
@@ -344,10 +209,11 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
labelLat={(d: any) => d.lat}
|
||||
labelLng={(d: any) => d.lng}
|
||||
labelDotRadius={0}
|
||||
labelSize={1.5}
|
||||
labelSize={(d: any) => d.size || 1.4}
|
||||
labelText="name"
|
||||
labelColor={(d: any) => d.color}
|
||||
labelResolution={2}
|
||||
labelAltitude={0.02} // float labels above polygon altitude to make them visible (Requirement 1)
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
export const getArcTooltip = (d: any): string => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: 180px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #facc15;">
|
||||
${d.fromLabel}
|
||||
</div>
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #38bdf8;">
|
||||
${d.toLabel}
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export const getPointTooltip = (d: any): string => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: ${d.value > 0 ? '180px' : 'auto'}; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; ${d.value > 0 ? 'border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px;' : ''}">${d.name}</div>
|
||||
${d.value > 0 ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
@@ -32,7 +32,7 @@ export function IpDetails({ ip }: { ip: string }) {
|
||||
setDetails(resolved);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("GeoIP lookup client error:", err);
|
||||
console.warn("GeoIP lookup client error:", err);
|
||||
if (isMounted) {
|
||||
setDetails({
|
||||
isp: "Public IP",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
totalResults: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
totalResults,
|
||||
startIndex,
|
||||
endIndex,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-3 border-t border-border/40 bg-white/[0.01] rounded-b-xl text-xs flex-wrap gap-2">
|
||||
<div className="text-muted-foreground">
|
||||
Showing <span className="font-medium text-slate-300">{startIndex + 1}</span> to{" "}
|
||||
<span className="font-medium text-slate-300">{Math.min(endIndex, totalResults)}</span> of{" "}
|
||||
<span className="font-medium text-slate-300">{totalResults}</span> results
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1 rounded bg-secondary hover:bg-secondary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-all border border-border/50 text-slate-300"
|
||||
title="Previous Page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="text-muted-foreground font-medium px-2">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1 rounded bg-secondary hover:bg-secondary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-all border border-border/50 text-slate-300"
|
||||
title="Next Page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// src/components/ui/globeOffsets.ts
|
||||
|
||||
export const SMALL_LABEL_COUNTRIES = new Set([
|
||||
// Europe
|
||||
'GB', 'IE', 'FR', 'DE', 'NL', 'BE', 'LU', 'CH', 'IT', 'ES', 'PT', 'GR', 'TR', 'CY',
|
||||
'NO', 'SE', 'FI', 'DK', 'PL', 'UA', 'BY', 'LT', 'LV', 'EE', 'RO', 'BG', 'HU', 'SK', 'CZ', 'AT', 'MD',
|
||||
// Central America & Caribbean
|
||||
'GP', 'TT', 'PA', 'CR', 'NI', 'HN', 'SV', 'GT', 'BZ', 'JM', 'PR', 'DO', 'HT', 'BS', 'VG', 'DM',
|
||||
// Indian Ocean
|
||||
'MU', 'RE', 'SC'
|
||||
]);
|
||||
|
||||
export function getStaggeredCoordinates(countryCode: string, startLat: number, startLng: number): { lat: number; lng: number } {
|
||||
let lat = startLat;
|
||||
let lng = startLng;
|
||||
|
||||
if (countryCode === 'SG') {
|
||||
lat += 1.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'MY') {
|
||||
lat += 3.0;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'VE') {
|
||||
lat += 2.5;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'CO') {
|
||||
lat += 1.0;
|
||||
lng -= 3.0;
|
||||
} else if (countryCode === 'EC') {
|
||||
lat -= 0.5;
|
||||
lng -= 3.5;
|
||||
} else if (countryCode === 'PE') {
|
||||
lat -= 1.0;
|
||||
lng -= 2.5;
|
||||
} else if (countryCode === 'BO') {
|
||||
lat -= 1.5;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'PY') {
|
||||
lat -= 1.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'UY') {
|
||||
lat -= 2.0;
|
||||
lng += 3.0;
|
||||
} else if (countryCode === 'BR') {
|
||||
lat += 1.5;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'CL') {
|
||||
lat -= 3.0;
|
||||
lng -= 2.0;
|
||||
} else if (countryCode === 'AR') {
|
||||
lat -= 3.5;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'SR') {
|
||||
lat += 1.5;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'GY') {
|
||||
lat += 2.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'PA') {
|
||||
lat -= 1.0;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'GT') {
|
||||
lat += 2.5;
|
||||
lng -= 2.0;
|
||||
} else if (countryCode === 'TT') {
|
||||
lat -= 1.5;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'GP') {
|
||||
lat += 3.0;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'PR') {
|
||||
lat += 1.5;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'VG') {
|
||||
lat += 2.2;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'DO') {
|
||||
lat -= 1.0;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'HT') {
|
||||
lat -= 1.5;
|
||||
lng -= 2.5;
|
||||
} else if (countryCode === 'JM') {
|
||||
lat -= 2.0;
|
||||
lng -= 4.0;
|
||||
} else if (countryCode === 'CR') {
|
||||
lat -= 0.5;
|
||||
lng -= 2.0;
|
||||
} else if (countryCode === 'NI') {
|
||||
lat += 0.5;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'HN') {
|
||||
lat += 1.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'SV') {
|
||||
lat -= 1.5;
|
||||
lng -= 2.5;
|
||||
} else if (countryCode === 'MU') {
|
||||
lat += 0.8;
|
||||
lng += 0.8;
|
||||
} else if (countryCode === 'RE') {
|
||||
lat -= 0.8;
|
||||
lng -= 0.8;
|
||||
} else if (countryCode === 'NO') {
|
||||
lat += 2.0;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'SE') {
|
||||
lat += 3.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'FI') {
|
||||
lat += 4.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'DK') {
|
||||
lat += 0.8;
|
||||
} else if (countryCode === 'NL') {
|
||||
lat += 1.5;
|
||||
lng += 0.5;
|
||||
} else if (countryCode === 'BE') {
|
||||
lat -= 0.5;
|
||||
lng -= 1.2;
|
||||
} else if (countryCode === 'LU') {
|
||||
lat -= 1.5;
|
||||
lng -= 0.8;
|
||||
} else if (countryCode === 'CH') {
|
||||
lat -= 2.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'DE') {
|
||||
lat += 1.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'AT') {
|
||||
lat -= 1.5;
|
||||
lng -= 0.5;
|
||||
} else if (countryCode === 'IT') {
|
||||
lat -= 4.0;
|
||||
lng -= 0.5;
|
||||
} else if (countryCode === 'ES') {
|
||||
lat -= 1.5;
|
||||
} else if (countryCode === 'PT') {
|
||||
lat -= 1.0;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'GR') {
|
||||
lat -= 4.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'EE') {
|
||||
lat += 2.0;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'LV') {
|
||||
lat += 1.2;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'LT') {
|
||||
lat += 0.5;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'PL') {
|
||||
lat += 1.0;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'UA') {
|
||||
lat += 0.5;
|
||||
lng += 3.0;
|
||||
} else if (countryCode === 'MD') {
|
||||
lat -= 0.2;
|
||||
lng += 2.5;
|
||||
} else if (countryCode === 'BY') {
|
||||
lat += 2.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'RO') {
|
||||
lat -= 1.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'BG') {
|
||||
lat -= 2.5;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'HU') {
|
||||
lat -= 2.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'SK') {
|
||||
lat -= 1.2;
|
||||
lng += 0.5;
|
||||
} else if (countryCode === 'CZ') {
|
||||
lat -= 0.5;
|
||||
lng -= 1.0;
|
||||
}
|
||||
|
||||
return { lat, lng };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// src/components/ui/useGlobeData.ts
|
||||
import { useMemo } from 'react';
|
||||
import { SMALL_LABEL_COUNTRIES, getStaggeredCoordinates } from './globeOffsets';
|
||||
|
||||
export interface GlobeDataInput {
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
endLat: number;
|
||||
endLng: number;
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
value: number;
|
||||
download: number;
|
||||
upload: number;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
export function useGlobeData(data: GlobeDataInput[], isLight: boolean) {
|
||||
const arcsData = useMemo(() => {
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
|
||||
}));
|
||||
}, [data, isLight]);
|
||||
|
||||
const pointsData = useMemo(() => {
|
||||
const points: any[] = [];
|
||||
const originSet = new Set<string>();
|
||||
const destSet = new Set<string>();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
points.push({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
size: 0.1,
|
||||
color: isLight ? '#0284c7' : '#38bdf8',
|
||||
name: arc.toLabel,
|
||||
value: arc.value,
|
||||
upload: arc.upload,
|
||||
download: arc.download,
|
||||
isOrigin: false
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
points.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
size: 0.2,
|
||||
color: '#f43f5e',
|
||||
name: arc.fromLabel,
|
||||
value: 0,
|
||||
upload: 0,
|
||||
download: 0,
|
||||
isOrigin: true
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
|
||||
return points;
|
||||
}, [arcsData, isLight]);
|
||||
|
||||
const labelsData = useMemo(() => {
|
||||
const labels: any[] = [];
|
||||
const originSet = new Set();
|
||||
const destSet = new Set();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
// Shift and stagger coordinates (Southeast Asia, Europe, Caribbean, Indian Ocean, Latin America)
|
||||
const { lat, lng } = getStaggeredCoordinates(arc.countryCode || '', arc.endLat, arc.endLng);
|
||||
const size = SMALL_LABEL_COUNTRIES.has(arc.countryCode || '') ? 0.9 : 1.4;
|
||||
|
||||
labels.push({
|
||||
lat,
|
||||
lng,
|
||||
name: arc.toLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc',
|
||||
size
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
labels.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
name: arc.fromLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc',
|
||||
size: 1.4
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
}, [arcsData, isLight]);
|
||||
|
||||
return { arcsData, pointsData, labelsData };
|
||||
}
|
||||
Reference in new issue
Block a user