feat: complete deployment codebase for BackOne DPI dashboard with dynamic telemetry, rebranding, database size tracking, and modular design

This commit is contained in:
Rafif-Riqullah-Siregar committed 2026-07-10 11:52:58 +07:00
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>
);
}
+77 -464
View File
@@ -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)}
/>
)}
</>
);
}
+91
View File
@@ -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>
);
}
+75
View File
@@ -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>
);
}
+118
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+164
View File
@@ -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>
);
}
+203
View File
@@ -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>
);
}
+179 -102
View File
@@ -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)}
/>
)}
</>
);
}
+75 -24
View File
@@ -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">
+73
View File
@@ -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>
);
}
+59 -442
View File
@@ -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>
</>
+73
View File
@@ -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>
);
}
+126
View File
@@ -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>
);
}
+100
View File
@@ -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>
);
}
+123
View File
@@ -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
View File
@@ -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>
);
+38
View File
@@ -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>
`;
+1 -1
View File
@@ -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",
+54
View File
@@ -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>
);
}
+183
View File
@@ -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 };
}
+109
View File
@@ -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 };
}