v1.1: Add CIDR subnet filtering and Agent filter in Devices page
This commit is contained in:
commit
966058e2fe
422 files changed
+54656
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface AgentAppItem {
|
||||
app_id: number | null;
|
||||
app_label: string;
|
||||
category: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
}
|
||||
|
||||
interface AgentAppsTabProps {
|
||||
topApps: AgentAppItem[];
|
||||
onSelectApp: (label: string) => void;
|
||||
}
|
||||
|
||||
export default function AgentAppsTab({ topApps, onSelectApp }: AgentAppsTabProps) {
|
||||
if (topApps.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">No applications accessed by this agent.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{topApps.map((app) => {
|
||||
const appTotal = app.download + app.upload;
|
||||
return (
|
||||
<button
|
||||
key={app.app_id}
|
||||
onClick={() => onSelectApp(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-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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchAgentDetails, AgentDetails } from "@/lib/api";
|
||||
import { fmtBytes, getAppFavicon } from "@/lib/utils";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { AppDetailModal } from "@/components/ui/AppDetailModal";
|
||||
import {
|
||||
Loader2, X, Server, Monitor, Activity, Globe, Shield,
|
||||
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" | "telemetry";
|
||||
|
||||
function EmptyState({ msg }: { msg: string }) {
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">{msg}</p>;
|
||||
}
|
||||
|
||||
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; agent_uuid?: string } | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [selectedAppLabel, setSelectedAppLabel] = useState<string | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 480);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
fetchAgentDetails(agentUuid)
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [agentUuid]);
|
||||
|
||||
// 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) +
|
||||
(data.security?.tor_detections?.length ?? 0) +
|
||||
(data.security?.vpn_detections?.length ?? 0)
|
||||
: 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: "telemetry", label: "Telemetry", icon: <LineChart className="w-3.5 h-3.5" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9999] 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-5xl 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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between px-4 py-4 sm:px-6 sm: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 min-w-0">
|
||||
<div className="p-1.5 sm:p-2 bg-blue-500/20 rounded-lg ring-1 ring-blue-500/30 flex-shrink-0">
|
||||
<Server className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base sm:text-lg font-bold text-card-foreground truncate">{agentLabel}</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground font-mono truncate">{agentUuid}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleClose} className="p-1.5 sm:p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all self-end sm:self-auto">
|
||||
<X className="w-4.5 h-4.5 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-4 py-3 sm:px-6 sm:py-4 border-b border-border/50 flex-shrink-0">
|
||||
{[
|
||||
{ label: "Devices", value: data.summary?.total_devices || data.devices.length, color: "text-card-foreground" },
|
||||
{ label: "Flows", value: data.summary?.active_flows || data.flows.length, color: "text-card-foreground" },
|
||||
{ label: "Download", value: fmtBytes(data.summary?.bandwidth_down || data.devices.reduce((acc, d) => acc + (d.download ?? 0), 0)), color: "text-cyan-400" },
|
||||
{ label: "Upload", value: fmtBytes(data.summary?.bandwidth_up || data.devices.reduce((acc, d) => acc + (d.upload ?? 0), 0)), color: "text-green-400" },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.label} className="bg-secondary rounded-xl p-2.5 sm:p-3 border border-border/50">
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-0.5 sm:mb-1">{kpi.label}</p>
|
||||
<p className={`text-base sm:text-xl font-bold ${kpi.color}`}>{kpi.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 px-4 sm:px-6 py-2 sm:py-3 border-b border-border/50 flex-shrink-0 overflow-x-auto flex-nowrap no-scrollbar select-none">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all flex-shrink-0 ${
|
||||
activeTab === tab.key
|
||||
? "bg-blue-500/20 text-blue-300 ring-1 ring-blue-500/40"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`ml-0.5 px-1.5 py-0.5 rounded text-[10px] ${activeTab === tab.key ? "bg-blue-500/30 text-blue-200" : "bg-white/10 text-slate-300"}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{activeTab === "devices" && (
|
||||
<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={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-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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === "security" && (
|
||||
<AgentSecurityTab data={data} />
|
||||
)}
|
||||
|
||||
{activeTab === "events" && (
|
||||
<AgentEventsTab events={data.events} />
|
||||
)}
|
||||
|
||||
{activeTab === "mac" && (
|
||||
<AgentMacTab macBandwidth={data.mac_bandwidth} />
|
||||
)}
|
||||
|
||||
{activeTab === "telemetry" && (
|
||||
<AgentTelemetryTab agentUuid={agentUuid} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{selectedDevice && selectedDevice.ip && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip}
|
||||
mac={selectedDevice.mac || ''}
|
||||
deviceData={{ agent_uuid: selectedDevice.agent_uuid || agentUuid } as any}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
{selectedAppLabel && (
|
||||
<AppDetailModal
|
||||
appLabel={selectedAppLabel}
|
||||
favicon={getAppFavicon(selectedAppLabel)}
|
||||
agentUuid={agentUuid}
|
||||
onClose={() => setSelectedAppLabel(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Monitor, ChevronRight } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
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 {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(devices.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "device_type", label: "Device Type", type: "select", value: filters.device_type, options: opts("device_type") },
|
||||
{ id: "os_label", label: "OS", type: "select", value: filters.os_label, options: opts("os_label") },
|
||||
{ id: "manufacturer", label: "Manufacturer", type: "select", value: filters.manufacturer, options: opts("manufacturer") },
|
||||
{ id: "risk_level", label: "Risk", type: "select", value: filters.risk_level, options: opts("risk_level") },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const filteredData = devices.filter((row: any) => {
|
||||
if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false;
|
||||
if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false;
|
||||
if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false;
|
||||
if (filters.risk_level && filters.risk_level !== "All" && row.risk_level !== filters.risk_level) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = processedData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<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.device_type && (
|
||||
<p className="text-xs font-semibold text-slate-300">
|
||||
{d.device_type}
|
||||
</p>
|
||||
)}
|
||||
{d.os_label && <p className="text-xs text-blue-400">{d.os_label}</p>}
|
||||
{d.mac_address && <p className="text-xs text-muted-foreground font-mono">{d.mac_address}</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>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
events: any[];
|
||||
}
|
||||
|
||||
function severityStyle(sev: string | null) {
|
||||
switch ((sev || "").toLowerCase()) {
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "high": return { badge: "bg-orange-500/15 text-orange-400 ring-orange-500/30", dot: "bg-orange-400" };
|
||||
case "warning": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "info": return { badge: "bg-blue-500/15 text-blue-400 ring-blue-500/30", dot: "bg-blue-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentEventsTab({ events }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(events.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = events.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{paginated.map((ev, i) => {
|
||||
const sc = severityStyle(ev.severity);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`p-3 border rounded-xl text-xs transition-colors ${
|
||||
(ev.severity || "").toLowerCase() === "critical"
|
||||
? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10"
|
||||
: (ev.severity || "").toLowerCase() === "high"
|
||||
? "bg-orange-500/5 border-orange-500/20 hover:bg-orange-500/10"
|
||||
: "bg-white/[0.03] border-border/50 hover:bg-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 flex-shrink-0 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{ev.severity || "—"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-semibold text-card-foreground">{ev.event_type || "Unknown"}</span>
|
||||
<span className="font-mono text-blue-400">{ev.ip_address || "—"}</span>
|
||||
{ev.mac_address && <span className="font-mono text-muted-foreground">{ev.mac_address}</span>}
|
||||
</div>
|
||||
{ev.description && <p className="text-muted-foreground leading-relaxed">{ev.description}</p>}
|
||||
</div>
|
||||
<span className="text-slate-600 flex-shrink-0 truncate">
|
||||
{ev.event_at ? ev.event_at.slice(0, 16).replace("T", " ") : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={events.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { IpDetails } from "./IpDetails";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
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-center table-fixed 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] text-center ${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 = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${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 {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(flows.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_label", label: "App", type: "select", value: filters.app_label, options: opts("app_label"), formatter: formatAppLabel },
|
||||
{ id: "protocol", label: "Protocol", type: "select", value: filters.protocol, options: opts("protocol"), formatter: formatAppLabel },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const filteredData = flows.filter((row: any) => {
|
||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_label) return false;
|
||||
if (filters.protocol && filters.protocol !== "All" && row.protocol !== filters.protocol) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = processedData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Source IP", width: "w-[15%]", align: "center" },
|
||||
{ header: "Destination", width: "w-[15%]", align: "center" },
|
||||
{ header: "App / Domain", width: "w-[20%]", align: "center" },
|
||||
{ header: "Protocol", width: "w-[10%]", align: "center" },
|
||||
{ header: "Download", width: "w-[10%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[10%]", align: "center" },
|
||||
{ header: "Last Seen", width: "w-[15%]", align: "center" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<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={startIndex + i + 1} center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.src_ip || "—"} className="font-mono text-blue-400" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 truncate w-full">
|
||||
<div className="font-mono text-slate-300 truncate text-center" 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 items-center justify-center gap-0.5 truncate w-full">
|
||||
<div className="font-semibold text-purple-300 truncate text-center" title={formatAppLabel(f.app_label) || "—"}>
|
||||
{formatAppLabel(f.app_label) || "—"}
|
||||
</div>
|
||||
{f.domain && (
|
||||
<div className="text-[10px] text-muted-foreground italic truncate text-center" 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]" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" center /></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" center /></td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
{totalPages > 1 && (
|
||||
<div className="pt-2">
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Wifi } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
macBandwidth: any[];
|
||||
}
|
||||
|
||||
export default function AgentMacTab({ macBandwidth }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(macBandwidth.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = macBandwidth.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{paginated.map((m, i) => {
|
||||
const maxTotal = macBandwidth.reduce((max: number, x: any) => Math.max(max, x.total ?? 0), 1);
|
||||
const pct = ((m.total ?? 0) / maxTotal) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Wifi className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-xs text-slate-200">{m.mac_address}</p>
|
||||
<p className="text-xs text-muted-foreground">{m.manufacturer || "Unknown Manufacturer"}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-card-foreground font-medium">{fmtBytes(m.total ?? 0)}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs mb-2">
|
||||
<span className="text-cyan-400">DL: {fmtBytes(m.download ?? 0)}</span>
|
||||
<span className="text-green-400">UL: {fmtBytes(m.upload ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={macBandwidth.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "./sharedPdfStyles";
|
||||
|
||||
interface AgentPdfDocumentProps {
|
||||
data: any;
|
||||
agentLabel: string;
|
||||
agentUuid: string;
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
interface TableColumn {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right" | "left";
|
||||
cell: (row: any) => string | number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1000;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr: string | Date): string {
|
||||
if (!dateStr) return "Never";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch (e) {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 15 }}>
|
||||
<Text style={sharedStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
{columns.map((col, idx) => (
|
||||
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
|
||||
{col.header}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No Data</Text>
|
||||
</View>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
|
||||
{col.cell(row)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentPdfDocument({
|
||||
data,
|
||||
agentLabel,
|
||||
agentUuid,
|
||||
reportDateStr,
|
||||
}: AgentPdfDocumentProps) {
|
||||
const summary = data.summary || {};
|
||||
|
||||
// Sort by total bandwidth (download + upload) descending for PDF export
|
||||
const sortByBandwidth = (arr: any[]) =>
|
||||
[...arr].sort((a, b) =>
|
||||
((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) -
|
||||
((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0))
|
||||
);
|
||||
|
||||
const devices = sortByBandwidth(data.devices || []).slice(0, 50);
|
||||
const flows = sortByBandwidth(data.flows || []).slice(0, 50);
|
||||
const topApps = sortByBandwidth(data.top_apps || []).slice(0, 50);
|
||||
const macBw = sortByBandwidth(data.mac_bandwidth || []).slice(0, 50);
|
||||
// Security alerts: not sorted by bandwidth — keep original order
|
||||
const insecure = (data.security?.insecure_protocols || []).slice(0, 50);
|
||||
const reputation = (data.security?.ip_reputation || []).slice(0, 50);
|
||||
const events = (data.events || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Network Agent Report</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Agent Info */}
|
||||
<Text style={sharedStyles.sectionTitle}>Agent Identification</Text>
|
||||
<View style={sharedStyles.grid}>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "35%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Agent Name</Text>
|
||||
<Text style={sharedStyles.gridValue}>{agentLabel || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "65%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Agent UUID</Text>
|
||||
<Text style={sharedStyles.gridValue}>{agentUuid || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.gridCol}>
|
||||
<Text style={sharedStyles.gridLabel}>Active Devices</Text>
|
||||
<Text style={sharedStyles.gridValue}>{summary.total_devices ?? 0}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.gridCol}>
|
||||
<Text style={sharedStyles.gridLabel}>Active Flows</Text>
|
||||
<Text style={sharedStyles.gridValue}>{summary.active_flows ?? 0}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bandwidth Usage */}
|
||||
<Text style={sharedStyles.sectionTitle}>Bandwidth Metrics</Text>
|
||||
<View style={sharedStyles.bandwidthGrid}>
|
||||
<View style={sharedStyles.bandwidthCardCyan}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#115e59" }}>{formatBytes(summary.bandwidth_down)}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.bandwidthCardGreen}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{formatBytes(summary.bandwidth_up)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Top Apps */}
|
||||
<RenderPdfTable
|
||||
title="Top Applications"
|
||||
data={topApps}
|
||||
columns={[
|
||||
{ header: "Application", width: "40%", cell: (r) => r.app_label || "Unknown" },
|
||||
{ header: "Category", width: "20%", cell: (r) => r.category || "—" },
|
||||
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Monitored Devices */}
|
||||
<RenderPdfTable
|
||||
title="Monitored Devices"
|
||||
data={devices}
|
||||
columns={[
|
||||
{ header: "IP Address", width: "22%", cell: (r) => r.ip_address || "N/A" },
|
||||
{ header: "OS / Manufacturer", width: "28%", cell: (r) => r.os_label || r.manufacturer || "Unknown" },
|
||||
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
{ header: "Last Seen", width: "20%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Recent Network Flows */}
|
||||
<RenderPdfTable
|
||||
title="Recent Network Flows"
|
||||
data={flows}
|
||||
columns={[
|
||||
{ header: "Source IP", width: "20%", cell: (r) => r.src_ip || "—" },
|
||||
{ header: "Destination IP", width: "20%", cell: (r) => r.dst_ip || "—" },
|
||||
{ header: "Port", width: "10%", align: "center", cell: (r) => r.dst_port || "—" },
|
||||
{ header: "App / Protocol", width: "20%", cell: (r) => r.app_label || r.protocol || "Unknown" },
|
||||
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Insecure Protocols */}
|
||||
<RenderPdfTable
|
||||
title="Insecure Protocols"
|
||||
data={insecure}
|
||||
columns={[
|
||||
{ header: "Host IP", width: "25%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Protocol", width: "15%", cell: (r) => r.protocol || "—" },
|
||||
{ header: "Risk Level", width: "15%", cell: (r) => r.risk || "—" },
|
||||
{ header: "Destination", width: "25%", cell: (r) => r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? ""}` : "—" },
|
||||
{ header: "Bytes Traced", width: "20%", align: "right", cell: (r) => formatBytes((r.download ?? 0) + (r.upload ?? 0)) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* IP Reputation Log */}
|
||||
<RenderPdfTable
|
||||
title="IP Reputation Log"
|
||||
data={reputation}
|
||||
columns={[
|
||||
{ header: "Remote IP", width: "30%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Local IP", width: "30%", cell: (r) => r.local_ip || "—" },
|
||||
{ header: "Reputation", width: "20%", cell: (r) => r.reputation || "—" },
|
||||
{ header: "Risk Score", width: "20%", align: "right", cell: (r) => r.score ?? "—" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Security Events */}
|
||||
<RenderPdfTable
|
||||
title="Security Events Log"
|
||||
data={events}
|
||||
columns={[
|
||||
{ header: "Event Type", width: "20%", cell: (r) => r.event_type || "—" },
|
||||
{ header: "Severity", width: "15%", align: "center", cell: (r) => r.severity || "—" },
|
||||
{ header: "IP Address", width: "20%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Description", width: "25%", cell: (r) => r.description || "—" },
|
||||
{ header: "Event Time", width: "20%", cell: (r) => r.event_at ? formatIndonesiaTime(r.event_at) : "—" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* MAC Bandwidth */}
|
||||
<RenderPdfTable
|
||||
title="MAC Bandwidth Distribution"
|
||||
data={macBw}
|
||||
columns={[
|
||||
{ header: "MAC Address", width: "30%", cell: (r) => r.mac_address || "—" },
|
||||
{ header: "Manufacturer", width: "30%", cell: (r) => r.manufacturer || "Unknown" },
|
||||
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"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-center table-fixed 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] text-center ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${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: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "IP", width: "w-[15%]", align: "center" },
|
||||
{ header: "Protocol", width: "w-[10%]", align: "center" },
|
||||
{ header: "Risk", width: "w-[15%]", align: "center" },
|
||||
{ header: "App", width: "w-[20%]", align: "center" },
|
||||
{ header: "Dst IP:Port", width: "w-[15%]", align: "center" },
|
||||
{ header: "Download", width: "w-[10%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[10%]", align: "center" },
|
||||
];
|
||||
|
||||
const REPUTATION_COLS: ColDef[] = [
|
||||
{ header: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Remote IP", width: "w-[20%]", align: "center" },
|
||||
{ header: "Local IP", width: "w-[20%]", align: "center" },
|
||||
{ header: "Reputation", width: "w-[25%]", align: "center" },
|
||||
{ header: "Score", width: "w-[10%]", align: "center" },
|
||||
{ header: "Country", width: "w-[10%]", align: "center" },
|
||||
{ header: "Status", width: "w-[10%]", align: "center" },
|
||||
];
|
||||
|
||||
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={i + 1} center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-blue-400" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.protocol || "—"} className="text-slate-200" center /></td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<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" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 truncate w-full">
|
||||
<div className="font-mono text-muted-foreground truncate text-center" 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" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(r.upload)} className="text-green-400 font-mono" center /></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={i + 1} center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-red-400" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.local_ip || "—"} className="font-mono text-blue-400" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.reputation || "—"} className="text-slate-300" center /></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" center /></td>
|
||||
<td className="px-3 py-2.5 text-center text-xs">
|
||||
{r.blacklisted ? <span className="text-red-400 font-semibold">Blocked</span> : <span className="text-green-400 font-semibold">Allowed</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnlockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// src/components/ui/AgentTelemetryTab.tsx
|
||||
"use client";
|
||||
|
||||
import { useTimeline } from "@/lib/api-with-context";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from "recharts";
|
||||
|
||||
interface Props {
|
||||
agentUuid: string;
|
||||
}
|
||||
|
||||
export default function AgentTelemetryTab({ agentUuid }: Props) {
|
||||
const { data, isLoading } = useTimeline(60, agentUuid);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-muted-foreground text-sm">
|
||||
No telemetry data recorded for this agent in the last 24 hours.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format time labels for XAxis
|
||||
const chartData = data.map(pt => {
|
||||
const date = new Date(pt.fetched_at);
|
||||
const dateStr = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||||
const timeStr = date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
const xAxisLabel = `${dateStr} ${timeStr}`;
|
||||
|
||||
const rawFullLabel = date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const fullLabel = rawFullLabel.replace(' at ', ', ') + ' WIB';
|
||||
|
||||
return {
|
||||
...pt,
|
||||
xAxisLabel,
|
||||
fullLabel,
|
||||
flows: pt.total_flows ?? 0,
|
||||
peak: pt.peak_flow_rate ?? 0,
|
||||
drops: pt.packet_drops ?? 0,
|
||||
cpu: pt.cpu_usage ?? 0,
|
||||
memory: pt.memory_usage ?? 0,
|
||||
queue: pt.queue_depth ?? 0
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-4">
|
||||
{/* Flow Rates Chart */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-blue-400">Flow Rates & Telemetry Peak</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="flowsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="peakGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="flows" name="Active Flows" stroke="#3b82f6" fillOpacity={1} fill="url(#flowsGrad)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="peak" name="Peak Flow Rate" stroke="#8b5cf6" fillOpacity={1} fill="url(#peakGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packet Drops Chart */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-rose-400">Packet Drop Rate Trend</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="dropsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="drops" name="Packet Drops (pkts/sec)" stroke="#f43f5e" fillOpacity={1} fill="url(#dropsGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Resource Telemetry (CPU & Memory) */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-emerald-400">Agent Resource Telemetry (CPU & RAM)</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="memGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} unit="%" />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="cpu" name="CPU Usage (%)" stroke="#10b981" fillOpacity={1} fill="url(#cpuGrad)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="memory" name="Memory Usage (%)" stroke="#f59e0b" fillOpacity={1} fill="url(#memGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Network Queue Depth */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-cyan-400">Network Packet Queue Depth Trend</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="queueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="queue" name="Queue Depth (packets)" stroke="#06b6d4" fillOpacity={1} fill="url(#queueGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"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-center table-fixed 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] text-center ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${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, 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);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setPage(1); // Reset page on query updates
|
||||
fetchAppDetails(appLabel, timeRange, agentUuid)
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [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-[9999] 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" />
|
||||
) : (
|
||||
<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 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: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Status / Last Seen", width: "w-[20%]", align: "center" },
|
||||
{ header: "IP Address", width: "w-[20%]", align: "center" },
|
||||
{ header: "Domain", width: "w-[25%]", align: "center" },
|
||||
{ header: "Download", width: "w-[15%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[15%]", align: "center" },
|
||||
]}>
|
||||
{paginatedIps.map((row: 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={startIndex + i + 1} center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<ActiveDurationDisplay firstSeen={row.first_seen} lastSeen={row.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(row.ip_address || row.dst_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full block text-center"
|
||||
title={row.ip_address || row.dst_ip || '-'}
|
||||
>
|
||||
{row.ip_address || row.dst_ip || '-'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={row.domain || '-'} className="text-slate-300" center />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono" center />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={fmtBytes(row.upload)} className="text-green-400 font-mono" center />
|
||||
</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>
|
||||
|
||||
{selectedIp && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedIp}
|
||||
onClose={() => setSelectedIp(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "./sharedPdfStyles";
|
||||
|
||||
interface AppPdfDocumentProps {
|
||||
data: any;
|
||||
appLabel: string;
|
||||
category: string;
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
interface TableColumn {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right" | "left";
|
||||
cell: (row: any) => string | number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1000;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr: string | Date): string {
|
||||
if (!dateStr) return "Never";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch (e) {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 15 }}>
|
||||
<Text style={sharedStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
{columns.map((col, idx) => (
|
||||
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
|
||||
{col.header}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No Data</Text>
|
||||
</View>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
|
||||
{col.cell(row)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppPdfDocument({
|
||||
data,
|
||||
appLabel,
|
||||
category,
|
||||
reportDateStr,
|
||||
}: AppPdfDocumentProps) {
|
||||
// Sort by total bandwidth (download + upload) descending for PDF export
|
||||
const sortByBandwidth = (arr: any[]) =>
|
||||
[...arr].sort((a, b) =>
|
||||
((b.download ?? 0) + (b.upload ?? 0)) - ((a.download ?? 0) + (a.upload ?? 0))
|
||||
);
|
||||
|
||||
const topIps = sortByBandwidth(data.top_ips || []).slice(0, 50);
|
||||
const agents = sortByBandwidth(data.agent_scorecard || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Application Activity Report</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Application Details */}
|
||||
<Text style={sharedStyles.sectionTitle}>Application Summary</Text>
|
||||
<View style={sharedStyles.grid}>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "50%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Application Name</Text>
|
||||
<Text style={sharedStyles.gridValue}>{appLabel}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "50%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Category</Text>
|
||||
<Text style={sharedStyles.gridValue}>{category || "General Web"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bandwidth Usage */}
|
||||
<Text style={sharedStyles.sectionTitle}>Traffic Metrics</Text>
|
||||
<View style={sharedStyles.bandwidthGrid}>
|
||||
<View style={sharedStyles.bandwidthCardCyan}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#115e59" }}>{formatBytes(data.total_download)}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.bandwidthCardGreen}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{formatBytes(data.total_upload)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Top Users */}
|
||||
<RenderPdfTable
|
||||
title="Top User Devices"
|
||||
data={topIps}
|
||||
columns={[
|
||||
{ header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
{ header: "Flows", width: "10%", align: "center", cell: (r) => r.flow_count ?? 0 },
|
||||
{ header: "Last Seen", width: "20%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Top Agents */}
|
||||
<RenderPdfTable
|
||||
title="Agent Telemetry Distribution"
|
||||
data={agents}
|
||||
columns={[
|
||||
{ header: "Agent Node", width: "50%", cell: (r) => r.agent_label || "Unknown Agent" },
|
||||
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
{ header: "Flows", width: "10%", align: "center", cell: (r) => r.flow_count ?? 0 },
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "red" | "orange" | "yellow" | "green" | "blue" | "cyan";
|
||||
}
|
||||
|
||||
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
"border-primary/20 bg-primary/10 text-primary": variant === "default",
|
||||
"border-red-500/20 bg-red-500/10 text-red-500": variant === "red",
|
||||
"border-orange-500/20 bg-orange-500/10 text-orange-500": variant === "orange",
|
||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-500": variant === "yellow",
|
||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-500": variant === "green",
|
||||
"border-blue-500/20 bg-blue-500/10 text-blue-500": variant === "blue",
|
||||
"border-cyan-500/20 bg-cyan-500/10 text-cyan-500": variant === "cyan",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card/80 backdrop-blur-xl text-card-foreground shadow-sm hover:shadow-primary/5 hover:border-primary/20 transition-all duration-300 ease-in-out hover:-translate-y-0.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col space-y-1.5 p-4 sm:p-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("p-4 sm:p-6 pt-0 sm:pt-0", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Search, Download, ChevronDown, FileText, FileSpreadsheet } from "lucide-react";
|
||||
import { CsvExportOptions, downloadCsv } from "./DataTableCsvHelper";
|
||||
import { DataTablePagination } from "./DataTablePagination";
|
||||
import { DataTableMobileCards } from "./DataTableMobileCards";
|
||||
|
||||
import { DataTableDesktopView } from "./DataTableDesktopView";
|
||||
|
||||
export interface Column<T> {
|
||||
header: React.ReactNode;
|
||||
accessor: (row: T, index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
sortable?: boolean;
|
||||
sortAccessor?: (row: T) => number | string | null | undefined;
|
||||
tooltip?: (row: T) => string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
searchPlaceholder?: string;
|
||||
searchFilter?: (row: T, query: string) => boolean;
|
||||
isLoading?: boolean;
|
||||
getRowClassName?: (row: T) => string;
|
||||
pageSize?: number;
|
||||
csvExport?: CsvExportOptions<T>;
|
||||
pdfExport?: { onExport: () => void; isExporting?: boolean; };
|
||||
onRowClick?: (row: T) => void;
|
||||
serverSide?: boolean;
|
||||
totalCount?: number;
|
||||
currentPage?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
data,
|
||||
columns,
|
||||
searchPlaceholder = "Search...",
|
||||
searchFilter,
|
||||
isLoading,
|
||||
getRowClassName,
|
||||
pageSize = 50,
|
||||
csvExport,
|
||||
pdfExport,
|
||||
onRowClick,
|
||||
serverSide = false,
|
||||
totalCount = 0,
|
||||
currentPage = 1,
|
||||
onPageChange,
|
||||
}: DataTableProps<T>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [localCurrentPage, setLocalCurrentPage] = useState(1);
|
||||
const [sortConfig, setSortConfig] = useState<{ key: number; direction: 'asc' | 'desc' } | null>(null);
|
||||
|
||||
const activePage = serverSide ? currentPage : localCurrentPage;
|
||||
const setActivePage = serverSide ? (onPageChange || (() => {})) : setLocalCurrentPage;
|
||||
|
||||
const handleSort = (key: number) => {
|
||||
const direction = sortConfig?.key === key && sortConfig.direction === 'asc' ? 'desc' : 'asc';
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
let processedData = [...data];
|
||||
|
||||
if (!serverSide && searchFilter && query.trim()) {
|
||||
processedData = processedData.filter(row => searchFilter(row, query.trim()));
|
||||
}
|
||||
|
||||
if (!serverSide && sortConfig !== null) {
|
||||
const column = columns[sortConfig.key];
|
||||
if (column && column.sortable && column.sortAccessor) {
|
||||
processedData.sort((a, b) => {
|
||||
const valA = column.sortAccessor!(a), valB = column.sortAccessor!(b);
|
||||
if (valA === valB) return 0;
|
||||
if (valA == null) return 1;
|
||||
if (valB == null) return -1;
|
||||
const asc = sortConfig.direction === 'asc';
|
||||
return valA < valB ? (asc ? -1 : 1) : (asc ? 1 : -1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredData = processedData;
|
||||
|
||||
const totalItems = serverSide ? totalCount : filteredData.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
const startIndex = (activePage - 1) * pageSize;
|
||||
const paginatedData = serverSide ? filteredData : filteredData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const exportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => exportRef.current && !exportRef.current.contains(e.target as Node) && setExportOpen(false);
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
}, []);
|
||||
|
||||
const handleCsvExport = () => {
|
||||
if (!csvExport) return;
|
||||
downloadCsv(csvExport.filename, [csvExport.headers, ...filteredData.map(csvExport.rowSerializer)]);
|
||||
setExportOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
{!serverSide && 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);
|
||||
setActivePage(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-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(csvExport || pdfExport) && (
|
||||
<div className="relative" ref={exportRef}>
|
||||
<button
|
||||
onClick={() => setExportOpen(prev => !prev)}
|
||||
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 ({totalItems})
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${exportOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<div className="absolute right-0 mt-1.5 w-44 rounded-xl border border-border/60 bg-slate-900/95 backdrop-blur-xl shadow-xl z-50 overflow-hidden">
|
||||
{csvExport && (
|
||||
<button
|
||||
onClick={handleCsvExport}
|
||||
className="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
)}
|
||||
{pdfExport && (
|
||||
<button
|
||||
onClick={() => { pdfExport.onExport(); setExportOpen(false); }}
|
||||
disabled={pdfExport.isExporting}
|
||||
className="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm text-blue-400 hover:bg-blue-500/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
{pdfExport.isExporting ? 'Generating...' : 'Export PDF'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Card View (< 768px) */}
|
||||
<DataTableMobileCards
|
||||
data={paginatedData}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
startIndex={startIndex}
|
||||
onRowClick={onRowClick}
|
||||
getRowClassName={getRowClassName}
|
||||
/>
|
||||
|
||||
{/* Desktop / Tablet Table View (>= 768px) */}
|
||||
<DataTableDesktopView
|
||||
data={paginatedData}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
activePage={activePage}
|
||||
pageSize={pageSize}
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
onRowClick={onRowClick}
|
||||
getRowClassName={getRowClassName}
|
||||
/>
|
||||
|
||||
<DataTablePagination
|
||||
totalItems={totalItems}
|
||||
pageSize={pageSize}
|
||||
currentPage={activePage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setActivePage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface CsvExportOptions<T> {
|
||||
filename: string;
|
||||
headers: string[];
|
||||
rowSerializer: (row: T) => (string | number | null | undefined)[];
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Column } from "./DataTable";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface DataTableDesktopViewProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
isLoading?: boolean;
|
||||
activePage: number;
|
||||
pageSize: number;
|
||||
sortConfig: { key: number; direction: 'asc' | 'desc' } | null;
|
||||
onSort: (key: number) => void;
|
||||
onRowClick?: (row: T) => void;
|
||||
getRowClassName?: (row: T) => string;
|
||||
}
|
||||
|
||||
export function DataTableDesktopView<T>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
activePage,
|
||||
pageSize,
|
||||
sortConfig,
|
||||
onSort,
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
}: DataTableDesktopViewProps<T>) {
|
||||
return (
|
||||
<div className="hidden md:block rounded-xl border border-border bg-[#0B0F19] shadow-sm overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm text-center table-fixed border-collapse">
|
||||
<thead className="bg-slate-900 border-b border-border/80">
|
||||
<tr className="bg-slate-900">
|
||||
{columns.map((col, i) => {
|
||||
const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-'));
|
||||
const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` };
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
onClick={() => col.sortable && onSort(i)}
|
||||
className={`px-4 py-3.5 h-[48px] font-semibold text-xs tracking-wider text-slate-300 bg-slate-900 border-b border-border/80 text-center ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-slate-800/80 transition-colors group select-none" : ""}`}
|
||||
style={widthStyle}
|
||||
>
|
||||
<div className="flex items-center gap-1 justify-center">
|
||||
{col.header}
|
||||
{col.sortable && (
|
||||
<div className="flex flex-col text-[10px] opacity-40 group-hover:opacity-100 transition-opacity">
|
||||
<span className={`${sortConfig?.key === i && sortConfig.direction === 'asc' ? 'text-primary opacity-100' : ''}`}>▲</span>
|
||||
<span className={`-mt-1 ${sortConfig?.key === i && sortConfig.direction === 'desc' ? 'text-primary opacity-100' : ''}`}>▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-3 animate-pulse">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl"></div>
|
||||
<svg className="animate-spin relative h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium tracking-wide">Loading data...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Search className="h-8 w-8 mb-3 opacity-20" />
|
||||
<span className="text-sm font-medium">No data found</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
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) => {
|
||||
const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-'));
|
||||
const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` };
|
||||
const content = col.accessor(row, (activePage - 1) * pageSize + i);
|
||||
const tooltipText = col.tooltip
|
||||
? col.tooltip(row)
|
||||
: (typeof content === 'string' || typeof content === 'number' ? String(content) : undefined);
|
||||
return (
|
||||
<td
|
||||
key={j}
|
||||
className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground text-center transition-colors ${col.className || ""}`}
|
||||
style={widthStyle}
|
||||
title={tooltipText}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Column } from "./DataTable";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableMobileCardsProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
isLoading?: boolean;
|
||||
startIndex: number;
|
||||
onRowClick?: (row: T) => void;
|
||||
getRowClassName?: (row: T) => string;
|
||||
}
|
||||
|
||||
export function DataTableMobileCards<T>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
startIndex,
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
}: DataTableMobileCardsProps<T>) {
|
||||
// Main title column (IP, Name, Label, App)
|
||||
const mainHeader = useMemo(() => {
|
||||
return (
|
||||
columns.find((c) => {
|
||||
const h = String(c.header || "").toLowerCase();
|
||||
return (
|
||||
h.includes("ip") ||
|
||||
h.includes("agent") ||
|
||||
h.includes("app") ||
|
||||
h.includes("label") ||
|
||||
h.includes("name")
|
||||
);
|
||||
}) ||
|
||||
columns[1] ||
|
||||
columns[0]
|
||||
);
|
||||
}, [columns]);
|
||||
|
||||
// Status / Severity / Risk badge column to pin to top right
|
||||
const statusHeader = useMemo(() => {
|
||||
return columns.find((c) => {
|
||||
const h = String(c.header || "").toLowerCase();
|
||||
return (
|
||||
h.includes("status") ||
|
||||
h.includes("state") ||
|
||||
h.includes("risk") ||
|
||||
h.includes("severity") ||
|
||||
h.includes("level")
|
||||
);
|
||||
});
|
||||
}, [columns]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="md:hidden p-6 text-center text-slate-400 bg-slate-900/60 border border-slate-800 rounded-2xl animate-pulse text-xs">
|
||||
Loading data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="md:hidden p-6 text-center text-slate-400 bg-slate-900/60 border border-slate-800 rounded-2xl text-xs font-medium">
|
||||
No data found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="md:hidden space-y-3.5 touch-pan-y pb-20">
|
||||
{data.map((row, i) => {
|
||||
const rowIndex = startIndex + i;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={cn(
|
||||
"bg-slate-900/90 border border-slate-800/80 rounded-2xl p-4 flex flex-col gap-3 shadow-lg backdrop-blur-md hover:border-blue-500/30 active:bg-slate-800/60 transition-all duration-150 touch-pan-y",
|
||||
onRowClick && "cursor-pointer active:scale-[0.99]",
|
||||
getRowClassName ? getRowClassName(row) : ""
|
||||
)}
|
||||
>
|
||||
{/* Header Row: Index + Primary Title on Left, Status Badge on Right */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800/70 pb-3 gap-2.5 min-w-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<span className="text-[10px] font-bold text-slate-300 font-mono shrink-0 bg-slate-800/90 px-2 py-0.5 rounded-md border border-slate-700/60 shadow-sm">
|
||||
#{rowIndex + 1}
|
||||
</span>
|
||||
<div className="text-sm font-bold text-blue-400 truncate tracking-tight">
|
||||
{mainHeader ? mainHeader.accessor(row, rowIndex) : null}
|
||||
</div>
|
||||
</div>
|
||||
{statusHeader && (
|
||||
<div className="shrink-0 text-xs font-medium">
|
||||
{statusHeader.accessor(row, rowIndex)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Structured Card Grid Items */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 text-xs">
|
||||
{columns.map((col, j) => {
|
||||
if (col === mainHeader || col === statusHeader) return null;
|
||||
const headerText =
|
||||
typeof col.header === "string" || typeof col.header === "number"
|
||||
? String(col.header)
|
||||
: "";
|
||||
if (headerText === "#" || headerText.trim() === "#") return null;
|
||||
|
||||
const val = col.accessor(row, rowIndex);
|
||||
if (val == null || val === "-") return null;
|
||||
|
||||
const lowerH = headerText.toLowerCase();
|
||||
const isFullWidth =
|
||||
lowerH.includes("mac") ||
|
||||
lowerH.includes("flow id") ||
|
||||
lowerH.includes("domain") ||
|
||||
lowerH.includes("url") ||
|
||||
lowerH.includes("action") ||
|
||||
lowerH.includes("mitigation") ||
|
||||
lowerH.includes("description") ||
|
||||
lowerH.includes("device") ||
|
||||
lowerH.includes("vendor") ||
|
||||
lowerH.includes("manufacturer") ||
|
||||
lowerH.includes("agent") ||
|
||||
lowerH.includes("probe");
|
||||
|
||||
const tooltip = col.tooltip ? col.tooltip(row) : undefined;
|
||||
|
||||
if (isFullWidth) {
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className="col-span-2 flex flex-col min-w-0 bg-slate-950/60 border border-slate-800/60 rounded-xl p-2.5"
|
||||
title={tooltip}
|
||||
>
|
||||
{headerText && (
|
||||
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider mb-1">
|
||||
{headerText}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-slate-200 font-medium break-all leading-relaxed">
|
||||
{val}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className="col-span-1 flex flex-col min-w-0 justify-center bg-slate-950/30 border border-slate-800/40 rounded-xl p-2.5"
|
||||
title={tooltip}
|
||||
>
|
||||
{headerText && (
|
||||
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider truncate mb-1">
|
||||
{headerText}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-slate-200 font-medium min-w-0 overflow-hidden text-ellipsis leading-tight">
|
||||
{val}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface DataTablePaginationProps {
|
||||
totalItems: number;
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function DataTablePagination({
|
||||
totalItems,
|
||||
pageSize,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: DataTablePaginationProps) {
|
||||
if (totalItems <= pageSize) return null;
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30 flex-wrap gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{startIndex + 1}</span> to{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(startIndex + pageSize, totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{totalItems}</span> results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50 cursor-pointer"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div className="text-sm text-muted-foreground font-medium px-2">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50 cursor-pointer"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Calendar, X } from "lucide-react";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
minDate: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
minDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
onChange,
|
||||
onClear,
|
||||
}: DateRangePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const active = !!(dateFrom || dateTo);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`flex items-center justify-between w-full px-3 py-2 text-xs rounded-lg border transition-all outline-none ${
|
||||
active
|
||||
? "border-primary/50 bg-primary/10 text-primary font-medium"
|
||||
: "border-border/50 bg-slate-900 text-slate-300 hover:border-slate-700 hover:text-white"
|
||||
}`}
|
||||
title="Filter by date"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span className="truncate">
|
||||
{active ? `${dateFrom || "..."} to ${dateTo || "..."}` : "Filter Date"}
|
||||
</span>
|
||||
</div>
|
||||
{active ? (
|
||||
<X
|
||||
className="w-3.5 h-3.5 cursor-pointer hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform duration-500 shrink-0 ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-900 border border-border/50 rounded-xl shadow-2xl z-50 p-4 min-w-[260px]">
|
||||
<p className="text-xs text-muted-foreground mb-3 font-medium">
|
||||
Filter Threats by Date
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase tracking-wider mb-1 block">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
min={minDate}
|
||||
max={dateTo || today}
|
||||
value={dateFrom}
|
||||
onChange={(e) => onChange(e.target.value, dateTo)}
|
||||
className="w-full bg-slate-800 border border-border/50 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] text-slate-400 uppercase tracking-wider mb-1 block">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
min={dateFrom || minDate}
|
||||
max={today}
|
||||
value={dateTo}
|
||||
onChange={(e) => onChange(dateFrom, e.target.value)}
|
||||
className="w-full bg-slate-800 border border-border/50 rounded-lg px-3 py-2 text-xs text-white focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
onChange(today, today);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-primary/10 text-primary border border-primary/30 rounded-lg hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 6);
|
||||
onChange(d.toISOString().slice(0, 10), today);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-secondary/50 text-white border border-border/50 rounded-lg hover:bg-secondary transition-colors"
|
||||
>
|
||||
7 Days
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 29);
|
||||
onChange(d.toISOString().slice(0, 10), today);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-secondary/50 text-white border border-border/50 rounded-lg hover:bg-secondary transition-colors"
|
||||
>
|
||||
30 Days
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClear();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="px-2 py-1.5 text-xs bg-red-500/10 text-red-400 border border-red-500/30 rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
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 {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(apps.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_label", label: "App", type: "select", value: filters.app_label, options: opts("app_label"), formatter: formatAppLabel },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const filteredData = apps.filter((row: any) => {
|
||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_label) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = processedData.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">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<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">
|
||||
{formatAppLabel(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>
|
||||
{app.last_seen && (
|
||||
<div className="flex justify-between mt-2 pt-2 border-t border-border/50">
|
||||
<span className="text-muted-foreground">Last Seen</span>
|
||||
<span className="text-muted-foreground font-medium">{new Date(app.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchDeviceDetails, DeviceDetails, DeviceStat } from "@/lib/api";
|
||||
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";
|
||||
import DeviceAppsTab from "./DeviceAppsTab";
|
||||
import DeviceProtocolsTab from "./DeviceProtocolsTab";
|
||||
import DeviceDomainsTab from "./DeviceDomainsTab";
|
||||
import DeviceFlowsTab from "./DeviceFlowsTab";
|
||||
import DeviceThreatsTab from "./DeviceThreatsTab";
|
||||
import DeviceIdentityCard from "./DeviceIdentityCard";
|
||||
import DeviceKpiSection from "./DeviceKpiSection";
|
||||
|
||||
interface Props {
|
||||
ip: string;
|
||||
mac?: string;
|
||||
deviceData?: DeviceStat;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "info" | "apps" | "protocols" | "domains" | "flows" | "threats";
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "info", label: "Overview", icon: Monitor },
|
||||
{ id: "apps", label: "Applications", icon: Box },
|
||||
{ id: "protocols", label: "Protocols", icon: Link2 },
|
||||
{ id: "domains", label: "Domains / Web", icon: Globe },
|
||||
{ id: "flows", label: "Network Flows", icon: Activity },
|
||||
{ id: "threats", label: "Threats", icon: Shield }
|
||||
];
|
||||
|
||||
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 handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => onClose(), 480);
|
||||
};
|
||||
|
||||
const loadData = async (silent = false) => {
|
||||
if (!silent) setIsLoading(true);
|
||||
else setIsRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const agentUuid = deviceData?.agent_uuid || '';
|
||||
const res = await fetchDeviceDetails(ip, '1d', mac, agentUuid);
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
if (!silent) setError(e.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData(false);
|
||||
}, [ip, mac]);
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const exportToPdf = async () => {
|
||||
if (!data) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { pdf } = await import("@react-pdf/renderer");
|
||||
const { default: DevicePdfDocument } = await import("./DevicePdfDocument");
|
||||
const reportDateStr = new Date().toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta", day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
}) + " WIB";
|
||||
|
||||
const doc = <DevicePdfDocument data={data} ip={ip || data.ip_address || ""} mac={mac || data.mac_address || ""} deviceData={deviceData} reportDateStr={reportDateStr} />;
|
||||
|
||||
const blob = await pdf(doc).toBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `Device_DPI_Telemetry_Report_${ip || data.ip_address || "device"}.pdf`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Failed to export PDF:", err);
|
||||
alert("Failed to generate PDF report. Please try again.");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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-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()}
|
||||
>
|
||||
<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 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between px-4 py-4 sm:px-6 sm: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-3 sm:gap-4 min-w-0">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-blue-500 blur-xl opacity-20 rounded-full" />
|
||||
<div className="p-2 sm:p-3 bg-gradient-to-br from-blue-500/20 to-cyan-500/10 rounded-lg sm:rounded-xl ring-1 ring-blue-500/30 relative">
|
||||
<Monitor className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<h3 className="text-base sm:text-xl font-bold text-card-foreground tracking-tight truncate max-w-full">
|
||||
{ip || data?.ip_address || 'Resolving IP...'}
|
||||
</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-blue-500 dark:text-blue-400 animate-spin flex-shrink-0" />}
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-0.5 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
<span className="truncate max-w-[150px] sm:max-w-none">{data?.device_label || deviceData?.device_label || 'Unknown Device'}</span>
|
||||
<span className="hidden sm:inline text-border">•</span>
|
||||
<span className="font-mono text-[10px] sm:text-xs bg-muted/60 dark:bg-slate-800/80 px-1.5 py-0.5 rounded leading-none">{mac || data?.mac_address || deviceData?.mac_address || 'No MAC'}</span>
|
||||
</p>
|
||||
{data?.agent_label && (
|
||||
<p className="text-[10px] sm:text-xs text-blue-600 dark:text-blue-400 mt-1 flex flex-wrap items-center gap-1">
|
||||
<Server className="w-3 h-3 flex-shrink-0" />
|
||||
<span>Source Agent:</span>
|
||||
<strong className="bg-blue-500/10 px-1 py-0.5 rounded">{data.agent_label}</strong>
|
||||
<span className="text-muted-foreground/60 truncate max-w-[100px] sm:max-w-none">({data.agent_uuid})</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between sm:justify-end gap-3 flex-shrink-0 w-full sm:w-auto border-t border-border/40 pt-3 sm:border-t-0 sm:pt-0">
|
||||
{data ? (
|
||||
<button
|
||||
disabled={isExporting}
|
||||
onClick={exportToPdf}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-lg sm:rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-3 h-3 sm:w-3.5 sm:h-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToLine className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||
)}
|
||||
Export PDF
|
||||
</button>
|
||||
) : <div />}
|
||||
<button onClick={handleClose} className="p-1.5 sm:p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg sm:rounded-xl transition-all">
|
||||
<X className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<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-muted-foreground font-medium">Gathering device telemetry...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6 text-center">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-2xl p-6 max-w-md">
|
||||
<X className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||
<h4 className="text-red-400 font-bold text-lg mb-1">Failed to Load</h4>
|
||||
<p className="text-red-300/70 text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 px-4 sm:px-6 py-2 sm:py-3 border-b border-border overflow-x-auto flex-nowrap bg-secondary/30 flex-shrink-0 no-scrollbar select-none">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const isActive = activeTab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`flex items-center gap-1.5 sm:gap-2.5 px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg sm:rounded-xl text-xs sm:text-sm font-semibold transition-all duration-300 flex-shrink-0 ${
|
||||
isActive
|
||||
? "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-3.5 h-3.5 sm:w-4 sm:h-4 ${isActive ? 'text-blue-600 dark:text-blue-400' : ''}`} />
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
{activeTab === "info" && (
|
||||
<div className="space-y-6 animate-fade-in text-xs">
|
||||
<ActiveDurationDisplay firstSeen={data.first_seen || undefined} lastSeen={data.last_seen || undefined} />
|
||||
|
||||
<DeviceKpiSection totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
|
||||
<DeviceIdentityCard
|
||||
osLabel={data.os_label || deviceData?.os_label || ""}
|
||||
deviceType={data.device_type || deviceData?.device_type || ""}
|
||||
manufacturer={data.manufacturer || deviceData?.manufacturer || ""}
|
||||
lastSeen={data.last_seen || deviceData?.last_seen || ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "apps" && (
|
||||
<DeviceAppsTab apps={data.apps} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{activeTab === "protocols" && (
|
||||
<DeviceProtocolsTab protocols={data.protocols} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{activeTab === "domains" && (
|
||||
<DeviceDomainsTab domains={data.domains} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{activeTab === "flows" && (
|
||||
<DeviceFlowsTab flows={data.flows} ip={ip} />
|
||||
)}
|
||||
|
||||
{activeTab === "threats" && (
|
||||
<DeviceThreatsTab threats={data.threats} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
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 {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(domains.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_label", label: "Domain", type: "select", value: filters.app_label, options: opts("app_label") },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const filteredData = domains.filter((row: any) => {
|
||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_label) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = processedData.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">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<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>
|
||||
{dom.last_seen && (
|
||||
<div className="flex justify-between mt-2 pt-2 border-t border-border/50">
|
||||
<span className="text-muted-foreground">Last Seen</span>
|
||||
<span className="text-muted-foreground font-medium">{new Date(dom.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
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-center table-fixed 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] text-center ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceFlowsTab({ flows, ip }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(flows.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_name", label: "App", type: "select", value: filters.app_name, options: opts("app_name"), formatter: formatAppLabel },
|
||||
{ id: "protocol_name", label: "Protocol", type: "select", value: filters.protocol_name, options: opts("protocol_name"), formatter: formatAppLabel },
|
||||
];
|
||||
|
||||
const filteredData = flows.filter((row: any) => {
|
||||
if (filters.app_name && filters.app_name !== "All" && row.app_name !== filters.app_name) return false;
|
||||
if (filters.protocol_name && filters.protocol_name !== "All" && row.protocol_name !== filters.protocol_name) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = filteredData.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: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Time", width: "w-[15%]", align: "center" },
|
||||
{ header: "Source", width: "w-[15%]", align: "center" },
|
||||
{ header: "Destination", width: "w-[15%]", align: "center" },
|
||||
{ header: "Port", width: "w-[10%]", align: "center" },
|
||||
{ header: "App / Protocol", width: "w-[20%]", align: "center" },
|
||||
{ header: "Download", width: "w-[10%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[10%]", align: "center" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => { handleFilterChange(id, val); setPage(1); }}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<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={startIndex + i + 1} center /></td>
|
||||
<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">
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" center /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
{totalPages > 1 && (
|
||||
<div className="pt-2">
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={filteredData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Server, Cpu, Monitor, HardDrive, Clock } from "lucide-react";
|
||||
|
||||
interface DeviceIdentityCardProps {
|
||||
osLabel: string;
|
||||
deviceType: string;
|
||||
manufacturer: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export default function DeviceIdentityCard({
|
||||
osLabel,
|
||||
deviceType,
|
||||
manufacturer,
|
||||
lastSeen,
|
||||
}: DeviceIdentityCardProps) {
|
||||
return (
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6 text-xs">
|
||||
<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-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">{osLabel || "Unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{deviceType || "Unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">{manufacturer || "Unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
{lastSeen ? new Date(lastSeen).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface DeviceKpiSectionProps {
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function DeviceKpiSection({ totalDownload, totalUpload }: DeviceKpiSectionProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 sm: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-12 h-12 sm:w-16 sm:h-16 text-cyan-500 dark:text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> Total Download
|
||||
</p>
|
||||
<p className="text-2xl sm:text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(totalDownload)}</p>
|
||||
</div>
|
||||
<div className="p-4 sm: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-12 h-12 sm:w-16 sm:h-16 text-emerald-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowUpFromLine className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> Total Upload
|
||||
</p>
|
||||
<p className="text-2xl sm:text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(totalUpload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { DeviceDetails, DeviceStat } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Server, Cpu, Monitor, HardDrive, Clock, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import ActiveDurationDisplay from "./ActiveDurationDisplay";
|
||||
|
||||
interface DeviceOverviewTabProps {
|
||||
data: DeviceDetails;
|
||||
deviceData?: DeviceStat;
|
||||
}
|
||||
|
||||
export default function DeviceOverviewTab({ data, deviceData }: DeviceOverviewTabProps) {
|
||||
return (
|
||||
<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/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-500 dark:text-cyan-400" />
|
||||
</div>
|
||||
<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-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p>
|
||||
</div>
|
||||
<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-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<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-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-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-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-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-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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { styles } from "./DevicePdfStyles";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
|
||||
interface DevicePdfDocumentProps {
|
||||
data: any;
|
||||
ip: string;
|
||||
mac: string;
|
||||
deviceData: any;
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
interface TableColumn {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right" | "left";
|
||||
cell: (row: any) => string | number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1000;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr: string | Date): string {
|
||||
if (!dateStr) return "Never";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch (e) {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 15 }}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
{columns.map((col, idx) => (
|
||||
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...styles.tableHeaderCell, textAlign: col.align || "left" }}>
|
||||
{col.header}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length === 0 ? (
|
||||
<View style={styles.tableRow}>
|
||||
<Text style={styles.emptyText}>No Data</Text>
|
||||
</View>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={styles.tableRow} wrap={false}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...styles.tableCell, textAlign: col.align || "left" }}>
|
||||
{col.cell(row)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DevicePdfDocument({
|
||||
data,
|
||||
ip,
|
||||
mac,
|
||||
deviceData,
|
||||
reportDateStr,
|
||||
}: DevicePdfDocumentProps) {
|
||||
// Sort by total bandwidth (download + upload) descending for PDF export
|
||||
const sortByBandwidth = (arr: any[]) =>
|
||||
[...arr].sort((a, b) =>
|
||||
((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) -
|
||||
((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0))
|
||||
);
|
||||
|
||||
const topApps = sortByBandwidth(data.apps || []).slice(0, 50);
|
||||
const topProtocols = sortByBandwidth(data.protocols || []).slice(0, 50);
|
||||
const topDomains = sortByBandwidth(data.domains || []).slice(0, 50);
|
||||
// Threats: sorted by detected_at descending (newest first), not by bandwidth
|
||||
const detectedThreats = [...(data.threats || [])]
|
||||
.sort((a, b) => new Date(b.detected_at || 0).getTime() - new Date(a.detected_at || 0).getTime())
|
||||
.slice(0, 50);
|
||||
const flows = sortByBandwidth(data.flows || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={styles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
|
||||
</View>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title}>Device Analysis Report</Text>
|
||||
<Text style={styles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Device Information */}
|
||||
<Text style={styles.sectionTitle}>Device Identification</Text>
|
||||
<View style={styles.grid}>
|
||||
<View style={styles.gridCol}>
|
||||
<Text style={styles.gridLabel}>IP Address</Text>
|
||||
<Text style={styles.gridValue}>{ip || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={styles.gridCol}>
|
||||
<Text style={styles.gridLabel}>MAC Address</Text>
|
||||
<Text style={styles.gridValue}>{mac || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.gridCol, width: "50%" }}>
|
||||
<Text style={styles.gridLabel}>Device OS</Text>
|
||||
<Text style={styles.gridValue}>{data.os_label || deviceData?.os_label || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={styles.gridCol}>
|
||||
<Text style={styles.gridLabel}>Manufacturer</Text>
|
||||
<Text style={styles.gridValue}>{data.manufacturer || deviceData?.manufacturer || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={styles.gridCol}>
|
||||
<Text style={styles.gridLabel}>Source Agent</Text>
|
||||
<Text style={styles.gridValue}>{data.agent_label || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.gridCol, width: "50%" }}>
|
||||
<Text style={styles.gridLabel}>Last Seen</Text>
|
||||
<Text style={styles.gridValue}>
|
||||
{data.last_seen || deviceData?.last_seen
|
||||
? formatIndonesiaTime(data.last_seen || deviceData?.last_seen)
|
||||
: "Never"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bandwidth Usage */}
|
||||
<Text style={styles.sectionTitle}>Bandwidth Metrics</Text>
|
||||
<View style={styles.bandwidthGrid}>
|
||||
<View style={styles.bandwidthCardCyan}>
|
||||
<Text style={{ ...styles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text>
|
||||
<Text style={{ ...styles.bandwidthValue, color: "#115e59" }}>{formatBytes(data.total_download)}</Text>
|
||||
</View>
|
||||
<View style={styles.bandwidthCardGreen}>
|
||||
<Text style={{ ...styles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text>
|
||||
<Text style={{ ...styles.bandwidthValue, color: "#166534" }}>{formatBytes(data.total_upload)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Security Threats Table */}
|
||||
<RenderPdfTable
|
||||
title={`Security Threats (${detectedThreats.length})`}
|
||||
data={detectedThreats}
|
||||
columns={[
|
||||
{ header: "Time", width: "25%", cell: (r) => r.detected_at ? formatIndonesiaTime(r.detected_at) : "N/A" },
|
||||
{ header: "Threat Type / Description", width: "35%", cell: (r) => r.threat_type || "Security Alert" },
|
||||
{ header: "Severity", width: "15%", align: "center", cell: (r) => (r.severity || "UNKNOWN").toUpperCase() },
|
||||
{ header: "Target IP", width: "25%", cell: (r) => r.dst_ip || r.ip_address || "—" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Top Applications */}
|
||||
<RenderPdfTable
|
||||
title="Top Applications (By Traffic)"
|
||||
data={topApps}
|
||||
columns={[
|
||||
{ header: "Application", width: "50%", cell: (r) => r.app_label || "Unknown" },
|
||||
{ header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Top Network Protocols */}
|
||||
<RenderPdfTable
|
||||
title="Top Protocols"
|
||||
data={topProtocols}
|
||||
columns={[
|
||||
{ header: "Protocol / Port", width: "50%", cell: (r) => formatAppLabel(r.app_label) },
|
||||
{ header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Top Visited Domains */}
|
||||
<RenderPdfTable
|
||||
title="Top Domains"
|
||||
data={topDomains}
|
||||
columns={[
|
||||
{ header: "Domain", width: "50%", cell: (r) => r.domain || "—" },
|
||||
{ header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Recent Network Flows */}
|
||||
<RenderPdfTable
|
||||
title="Recent Network Flows"
|
||||
data={flows}
|
||||
columns={[
|
||||
{ header: "Source IP", width: "20%", cell: (r) => r.src_ip || "—" },
|
||||
{ header: "Destination IP", width: "20%", cell: (r) => r.dst_ip || "—" },
|
||||
{ header: "Port", width: "10%", align: "center", cell: (r) => r.dst_port || "—" },
|
||||
{ header: "App / Protocol", width: "20%", cell: (r) => r.app_label || r.protocol || "Unknown" },
|
||||
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 30,
|
||||
fontSize: 9,
|
||||
fontFamily: "Helvetica",
|
||||
color: "#334155",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1.5,
|
||||
borderBottomColor: "#3b82f6",
|
||||
paddingBottom: 12,
|
||||
marginBottom: 15,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#1e3a8a",
|
||||
},
|
||||
logoSubtext: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
marginTop: 2,
|
||||
},
|
||||
titleContainer: {
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
color: "#0f172a",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
marginTop: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
color: "#1e3a8a",
|
||||
backgroundColor: "#f8fafc",
|
||||
padding: "4 8",
|
||||
marginTop: 15,
|
||||
marginBottom: 8,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: "#3b82f6",
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
marginBottom: 10,
|
||||
},
|
||||
gridCol: {
|
||||
width: "25%",
|
||||
marginBottom: 8,
|
||||
},
|
||||
gridLabel: {
|
||||
fontSize: 7,
|
||||
color: "#64748b",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 2,
|
||||
},
|
||||
gridValue: {
|
||||
fontSize: 8.5,
|
||||
fontWeight: "bold",
|
||||
color: "#0f172a",
|
||||
},
|
||||
bandwidthGrid: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
},
|
||||
bandwidthCardCyan: {
|
||||
width: "48%",
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e2e8f0",
|
||||
backgroundColor: "#f0fdfa",
|
||||
},
|
||||
bandwidthCardGreen: {
|
||||
width: "48%",
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e2e8f0",
|
||||
backgroundColor: "#f0fdf4",
|
||||
},
|
||||
bandwidthLabel: {
|
||||
fontSize: 7.5,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 3,
|
||||
},
|
||||
bandwidthValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
marginBottom: 10,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: "#f1f5f9",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#cbd5e1",
|
||||
padding: "4 6",
|
||||
},
|
||||
tableHeaderCell: {
|
||||
fontSize: 7.5,
|
||||
fontWeight: "bold",
|
||||
color: "#475569",
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f1f5f9",
|
||||
padding: "4 6",
|
||||
},
|
||||
tableCell: {
|
||||
fontSize: 7.5,
|
||||
color: "#334155",
|
||||
},
|
||||
alertBox: {
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#fef2f2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#fecaca",
|
||||
marginBottom: 10,
|
||||
},
|
||||
alertTitle: {
|
||||
fontSize: 8.5,
|
||||
fontWeight: "bold",
|
||||
color: "#991b1b",
|
||||
marginBottom: 2,
|
||||
},
|
||||
alertText: {
|
||||
fontSize: 7.5,
|
||||
color: "#7f1d1d",
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
fontStyle: "italic",
|
||||
padding: 6,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
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 {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(protocols.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_label", label: "Protocol", type: "select", value: filters.app_label, options: opts("app_label"), formatter: formatAppLabel },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const filteredData = protocols.filter((row: any) => {
|
||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_label) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = processedData.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">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<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">
|
||||
{formatAppLabel(proto.app_label)}
|
||||
<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">{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>
|
||||
{proto.last_seen && (
|
||||
<div className="flex justify-between mt-2 pt-2 border-t border-border/50">
|
||||
<span className="text-muted-foreground">Last Seen</span>
|
||||
<span className="text-muted-foreground font-medium">{new Date(proto.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Shield } from "lucide-react";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
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-center table-fixed 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] text-center ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceThreatsTab({ threats }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(threats.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "severity", label: "Severity", type: "select", value: filters.severity, options: opts("severity") },
|
||||
{ id: "threat_type", label: "Threat Type", type: "select", value: filters.threat_type, options: opts("threat_type") },
|
||||
];
|
||||
|
||||
const filteredData = threats.filter((row: any) => {
|
||||
if (filters.severity && filters.severity !== "All" && row.severity !== filters.severity) return false;
|
||||
if (filters.threat_type && filters.threat_type !== "All" && row.threat_type !== filters.threat_type) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = filteredData.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: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Time", width: "w-[25%]", align: "center" },
|
||||
{ header: "Type", width: "w-[40%]", align: "center" },
|
||||
{ header: "Severity", width: "w-[15%]", align: "center" },
|
||||
{ header: "Target IP", width: "w-[15%]", align: "center" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => { handleFilterChange(id, val); setPage(1); }}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<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={startIndex + i + 1} center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={timeStr} className="text-muted-foreground" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<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 text-center"
|
||||
>
|
||||
{t.threat_type || "-"}
|
||||
</div>
|
||||
</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" center /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
{totalPages > 1 && (
|
||||
<div className="pt-2">
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={filteredData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
interface DropdownFilterSelectProps {
|
||||
value: string;
|
||||
options: string[];
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
optionFormatter?: (v: string) => string;
|
||||
}
|
||||
|
||||
export function DropdownFilterSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder,
|
||||
optionFormatter,
|
||||
}: DropdownFilterSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const active = value !== "All";
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, []);
|
||||
|
||||
const displayValue = active
|
||||
? optionFormatter
|
||||
? optionFormatter(value)
|
||||
: value
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`flex items-center justify-between w-full px-3 py-2 text-xs rounded-lg border transition-all text-left outline-none ${
|
||||
active
|
||||
? "border-primary/50 bg-primary/10 text-primary font-medium"
|
||||
: "border-border/50 bg-slate-900 text-slate-300 hover:border-slate-700 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{displayValue}</span>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform duration-500 shrink-0 ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Floating Pop-up with 0.5s transition animations (origin-top) */}
|
||||
<div
|
||||
className={`absolute top-full left-0 right-0 mt-1 max-h-[200px] overflow-y-auto bg-slate-900 border border-border/50 rounded-lg shadow-xl z-50 py-1 transition-all duration-500 ease-in-out origin-top ${
|
||||
open
|
||||
? "opacity-100 scale-y-100 translate-y-0 pointer-events-auto"
|
||||
: "opacity-0 scale-y-90 -translate-y-2 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onChange("All");
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-1.5 text-xs text-left transition-colors ${
|
||||
value === "All"
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{options.map((opt) => {
|
||||
const display = optionFormatter ? optionFormatter(opt) : opt;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => {
|
||||
onChange(opt);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-1.5 text-xs text-left transition-colors truncate ${
|
||||
value === opt
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||
}`}
|
||||
title={display}
|
||||
>
|
||||
{display}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { ComposableMap, Geographies, Geography, Line, Marker, ZoomableGroup } from "react-simple-maps";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useGlobeData, GlobeDataInput } from './useGlobeData';
|
||||
|
||||
const geoUrl = "/ne_110m_admin_0_countries.geojson";
|
||||
|
||||
interface GlobeMapProps {
|
||||
data: GlobeDataInput[];
|
||||
}
|
||||
|
||||
export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
const [position, setPosition] = useState({ coordinates: [0, 20], zoom: 1 });
|
||||
function handleMoveEnd(newPosition: any) {
|
||||
setPosition(newPosition);
|
||||
}
|
||||
|
||||
const { arcsData, pointsData, labelsData } = useGlobeData(data, isLight);
|
||||
|
||||
const mapBgColor = "transparent";
|
||||
const mapBorderColor = isLight ? "#ffffff" : "#0ea5e9";
|
||||
const mapFillColor = isLight ? "#4ade80" : "#0f172a";
|
||||
const mapHoverFill = isLight ? "#22c55e" : "#1e293b";
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[500px] rounded-xl overflow-hidden bg-transparent flex items-center justify-center cursor-grab active:cursor-grabbing">
|
||||
<style>{`
|
||||
@keyframes flowDashGlobe {
|
||||
to {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
.globe-animated-line {
|
||||
animation: flowDashGlobe 1.5s linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 120,
|
||||
center: [0, 20]
|
||||
}}
|
||||
width={800}
|
||||
height={500}
|
||||
style={{ width: "100%", height: "100%", outline: "none" }}
|
||||
>
|
||||
<ZoomableGroup
|
||||
zoom={position.zoom}
|
||||
center={position.coordinates as [number, number]}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
minZoom={1}
|
||||
maxZoom={8}
|
||||
>
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={mapFillColor}
|
||||
stroke={mapBorderColor}
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
hover: { fill: mapHoverFill, outline: "none" },
|
||||
pressed: { outline: "none" },
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Geographies>
|
||||
|
||||
{arcsData.map((arc: any, i: number) => {
|
||||
const isLocal = arc.startLng === arc.endLng && arc.startLat === arc.endLat;
|
||||
const color = Array.isArray(arc.color) ? arc.color[0] : arc.color || '#0ea5e9';
|
||||
|
||||
if (isLocal) {
|
||||
return (
|
||||
<Marker key={`arc-${i}`} coordinates={[arc.startLng, arc.startLat]}>
|
||||
<circle r={8} fill="none" stroke={color} strokeWidth={1.5} opacity={0.6} strokeDasharray="2,2" />
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
|
||||
// Force the line to go straight across the map (the "front")
|
||||
// by interpolating multiple points. This prevents d3-geo from
|
||||
// wrapping around the dateline for long distances.
|
||||
const coordinates = [];
|
||||
const steps = 30;
|
||||
for (let j = 0; j <= steps; j++) {
|
||||
const t = j / steps;
|
||||
let lng = arc.startLng + (arc.endLng - arc.startLng) * t;
|
||||
|
||||
// Dynamic arc height based on distance
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(arc.endLng - arc.startLng, 2) +
|
||||
Math.pow(arc.endLat - arc.startLat, 2)
|
||||
);
|
||||
const height = Math.min(dist * 0.15, 25);
|
||||
let lat = arc.startLat + (arc.endLat - arc.startLat) * t + Math.sin(t * Math.PI) * height;
|
||||
|
||||
coordinates.push([lng, lat]);
|
||||
}
|
||||
|
||||
return (
|
||||
<Line
|
||||
key={`arc-${i}`}
|
||||
coordinates={coordinates}
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeOpacity={0.9}
|
||||
strokeDasharray="2 4 4 4 8 78"
|
||||
className="globe-animated-line"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{pointsData.map((pt: any, i: number) => (
|
||||
<Marker key={`marker-${i}`} coordinates={[pt.lng, pt.lat]}>
|
||||
<circle r={pt.size * 2} fill={pt.color} opacity={0.8} />
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{labelsData.map((lbl: any, i: number) => (
|
||||
<Marker key={`label-${i}`} coordinates={[lbl.lng, lbl.lat]}>
|
||||
<text
|
||||
textAnchor="middle"
|
||||
y={-10}
|
||||
style={{
|
||||
fontFamily: "system-ui",
|
||||
fill: lbl.color,
|
||||
fontSize: `${lbl.size * 6}px`,
|
||||
fontWeight: "bold",
|
||||
pointerEvents: "none"
|
||||
}}
|
||||
>
|
||||
{lbl.name}
|
||||
</text>
|
||||
</Marker>
|
||||
))}
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
export const getArcTooltip = (d: any): string => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: 180px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #facc15;">
|
||||
${d.fromLabel}
|
||||
</div>
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #38bdf8;">
|
||||
${d.toLabel}
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export const getPointTooltip = (d: any): string => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: ${d.value > 0 ? '180px' : 'auto'}; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; ${d.value > 0 ? 'border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px;' : ''}">${d.name}</div>
|
||||
${d.value > 0 ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { HelpCircle, X, Search, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTenantConfig } from '@/contexts/TenantConfigContext';
|
||||
import { FAQS } from '@/lib/helpContent';
|
||||
|
||||
const FAQItem = ({ question, answer }: { question: string, answer: string }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/50 py-3">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex w-full items-center justify-between text-left focus:outline-none"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{question}</span>
|
||||
{isOpen ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-2 text-sm text-muted-foreground leading-relaxed">
|
||||
{answer}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function HelpCenterFAB() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { tenantConfig } = useTenantConfig();
|
||||
|
||||
const brandName = tenantConfig?.brandName || "System";
|
||||
const footerCopyright = tenantConfig?.footerCopyright || `${brandName} © 2026`;
|
||||
|
||||
const filteredFaqs = FAQS.filter(
|
||||
faq =>
|
||||
faq.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
faq.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<motion.button
|
||||
onClick={() => setIsOpen(true)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="fixed bottom-24 lg:bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-[0_0_20px_rgba(37,99,235,0.4)] hover:bg-blue-500 transition-colors"
|
||||
aria-label="Help and Resources"
|
||||
>
|
||||
<HelpCircle className="h-6 w-6" />
|
||||
{/* Pulse effect */}
|
||||
<span className="absolute -inset-1 animate-ping rounded-full bg-blue-400 opacity-20"></span>
|
||||
</motion.button>
|
||||
|
||||
{/* Drawer Overlay */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="fixed inset-0 z-50 bg-background/40 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
{/* Drawer Panel */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed right-0 top-0 z-50 flex h-full w-full max-w-sm flex-col bg-card/95 backdrop-blur-xl border-l border-border shadow-2xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-6 py-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground">Help & Resources</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{brandName} Guide & Documentation</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-8">
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search articles or FAQs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-secondary/50 py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* FAQs */}
|
||||
<div className="space-y-3 pb-8">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4" /> Frequently Asked Questions
|
||||
</h3>
|
||||
<div className="rounded-xl border border-border/50 bg-card px-4">
|
||||
{filteredFaqs.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-4 text-center">No matching FAQs found</p>
|
||||
) : (
|
||||
filteredFaqs.map((faq, index) => (
|
||||
<FAQItem key={index} question={faq.question} answer={faq.answer} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border/50 bg-secondary/30 p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: footerCopyright }} />
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// Client-side cache to prevent duplicate fetches for the same IP
|
||||
const geoIpCache: Record<string, { isp: string; country: string; as_org: string }> = {};
|
||||
|
||||
export function IpDetails({ ip }: { ip: string }) {
|
||||
const [details, setDetails] = useState<{ isp: string; country: string; as_org: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ip) return;
|
||||
|
||||
// Check memory cache first
|
||||
if (geoIpCache[ip]) {
|
||||
setDetails(geoIpCache[ip]);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
fetch(`/api/auth/geoip?ip=${encodeURIComponent(ip)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!isMounted) return;
|
||||
const resolved = {
|
||||
isp: data.isp || "Unknown ISP",
|
||||
country: data.country || "Remote",
|
||||
as_org: data.as_org || "Public Network",
|
||||
};
|
||||
geoIpCache[ip] = resolved;
|
||||
setDetails(resolved);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("GeoIP lookup client error:", err);
|
||||
if (isMounted) {
|
||||
setDetails({
|
||||
isp: "Public IP",
|
||||
country: "Remote",
|
||||
as_org: "Public Network",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [ip]);
|
||||
|
||||
if (!details) {
|
||||
return <span className="text-[10px] text-slate-500 animate-pulse">loading...</span>;
|
||||
}
|
||||
|
||||
const isLocal = details.country === "Local";
|
||||
|
||||
return (
|
||||
<div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate" title={`${details.isp}${!isLocal ? ` • ${details.country}` : ''}`}>
|
||||
<span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}>
|
||||
{details.isp}
|
||||
</span>
|
||||
{!isLocal && (
|
||||
<span className="text-slate-400 font-normal">
|
||||
{" • "}{details.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setVisible(true);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
if (visible) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "unset";
|
||||
}, 480);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [isOpen, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isMounted || !visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className={cn(
|
||||
"relative z-[9999] w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl sm:my-8",
|
||||
isClosing ? 'animate-modal-out' : 'animate-modal-in',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-foreground tracking-tight">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
totalResults: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
totalResults,
|
||||
startIndex,
|
||||
endIndex,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-3 border-t border-border/40 bg-white/[0.01] rounded-b-xl text-xs flex-wrap gap-2">
|
||||
<div className="text-muted-foreground">
|
||||
Showing <span className="font-medium text-slate-300">{startIndex + 1}</span> to{" "}
|
||||
<span className="font-medium text-slate-300">{Math.min(endIndex, totalResults)}</span> of{" "}
|
||||
<span className="font-medium text-slate-300">{totalResults}</span> results
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1 rounded bg-secondary hover:bg-secondary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-all border border-border/50 text-slate-300"
|
||||
title="Previous Page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="text-muted-foreground font-medium px-2">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1 rounded bg-secondary hover:bg-secondary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-all border border-border/50 text-slate-300"
|
||||
title="Next Page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchRemoteIpDetails } from "@/lib/api";
|
||||
import { RemoteIpDetails } from "@/lib/api-detail-types";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, X, Globe, Activity, Shield, Box, Server, Clock, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Link2, Laptop } from "lucide-react";
|
||||
import ActiveDurationDisplay from "./ActiveDurationDisplay";
|
||||
import { IpDetails } from "./IpDetails";
|
||||
|
||||
import RemoteIpLocalDevicesTab from "./RemoteIpLocalDevicesTab";
|
||||
import DeviceProtocolsTab from "./DeviceProtocolsTab";
|
||||
import DeviceDomainsTab from "./DeviceDomainsTab";
|
||||
import DeviceFlowsTab from "./DeviceFlowsTab";
|
||||
import DeviceThreatsTab from "./DeviceThreatsTab";
|
||||
|
||||
interface Props {
|
||||
ip: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "info" | "local_devices" | "protocols" | "domains" | "flows" | "threats";
|
||||
|
||||
export function RemoteIpDetailModal({ ip, onClose }: Props) {
|
||||
const [data, setData] = useState<RemoteIpDetails | 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 handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => onClose(), 480);
|
||||
};
|
||||
|
||||
const loadData = async (silent = false) => {
|
||||
if (!silent) setIsLoading(true);
|
||||
else setIsRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchRemoteIpDetails(ip, '1d');
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
if (!silent) setError(e.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData(false);
|
||||
}, [ip]);
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "info", label: "Overview", icon: Globe },
|
||||
{ id: "local_devices", label: "Local Devices", icon: Laptop },
|
||||
{ id: "protocols", label: "Protocols", icon: Link2 },
|
||||
{ id: "domains", label: "Domains", icon: Box },
|
||||
{ id: "flows", label: "Flows", icon: Activity },
|
||||
{ id: "threats", label: "Threats", icon: Shield }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] 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-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()}
|
||||
>
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-indigo-500/50 to-transparent" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between px-4 py-4 sm:px-6 sm:py-5 border-b border-border bg-gradient-to-br from-indigo-500/5 via-slate-500/5 to-slate-500/10 dark:from-indigo-900/10 dark:via-slate-900/20 dark:to-slate-950/40 relative z-10">
|
||||
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="absolute inset-0 bg-indigo-500 blur-xl opacity-20 rounded-full" />
|
||||
<div className="p-2 sm:p-3 bg-gradient-to-br from-indigo-500/20 to-blue-500/10 rounded-lg sm:rounded-xl ring-1 ring-indigo-500/30 relative">
|
||||
<Globe className="w-5 h-5 sm:w-6 sm:h-6 text-indigo-500 dark:text-indigo-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<h3 className="text-base sm:text-xl font-bold text-card-foreground tracking-tight truncate">{ip}</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-indigo-500 dark:text-indigo-400 animate-spin flex-shrink-0" />}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<IpDetails ip={ip} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between sm:justify-end gap-2 flex-shrink-0 w-full sm:w-auto border-t border-border/40 pt-3 sm:border-t-0 sm:pt-0">
|
||||
<button
|
||||
onClick={() => loadData(true)}
|
||||
disabled={isLoading || isRefreshing}
|
||||
className="p-2 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-full transition-all disabled:opacity-50"
|
||||
title="Refresh Data"
|
||||
>
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-full transition-all bg-black/5 dark:bg-white/5"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex px-6 border-b border-border bg-muted/20 overflow-x-auto no-scrollbar">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-5 py-4 border-b-2 font-medium text-sm transition-all whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400 bg-indigo-500/5'
|
||||
: 'border-transparent text-muted-foreground hover:text-card-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className={`w-4 h-4 ${activeTab === tab.id ? '' : 'opacity-70'}`} />
|
||||
{tab.label}
|
||||
{data && tab.id === 'flows' && data.flows.length > 0 && (
|
||||
<span className="ml-1.5 px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-800 text-[10px] text-muted-foreground font-semibold">
|
||||
{data.flows.length}
|
||||
</span>
|
||||
)}
|
||||
{data && tab.id === 'threats' && data.threats.length > 0 && (
|
||||
<span className="ml-1.5 px-2 py-0.5 rounded-full bg-destructive/10 text-destructive text-[10px] font-semibold ring-1 ring-destructive/20">
|
||||
{data.threats.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 bg-gradient-to-b from-transparent to-muted/10 relative">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 animate-in fade-in duration-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Loading remote IP intelligence...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-destructive">
|
||||
<Shield className="w-10 h-10 mb-2 opacity-50" />
|
||||
<p className="font-semibold text-lg">Error loading data</p>
|
||||
<p className="text-sm opacity-80">{error}</p>
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="h-full animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
{activeTab === "info" && (
|
||||
<div className="space-y-6">
|
||||
<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/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-500 dark:text-cyan-400" />
|
||||
</div>
|
||||
<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 (to Remote IP)
|
||||
</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/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-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<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 (from Remote IP)
|
||||
</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-indigo-500" /> Network Properties
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Globe className="w-3.5 h-3.5" /> IP Version</p>
|
||||
<p className="text-card-foreground font-medium text-sm">IPv{data.ip_version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Laptop className="w-3.5 h-3.5" /> Interacting Devices</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.local_devices?.length || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Link2 className="w-3.5 h-3.5" /> Flow Count</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.flows?.length || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<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 ? new Date(data.last_seen).toLocaleString() : 'Never'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "local_devices" && (
|
||||
<RemoteIpLocalDevicesTab devices={data.local_devices} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{activeTab === "protocols" && (
|
||||
<DeviceProtocolsTab protocols={data.protocols} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{activeTab === "domains" && (
|
||||
<DeviceDomainsTab domains={data.domains} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{activeTab === "flows" && (
|
||||
<DeviceFlowsTab flows={data.flows} ip={ip} />
|
||||
)}
|
||||
|
||||
{activeTab === "threats" && (
|
||||
<DeviceThreatsTab threats={data.threats} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Laptop } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
interface Props {
|
||||
devices: any[];
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function RemoteIpLocalDevicesTab({ devices, totalDownload, totalUpload }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
let processedData = [...devices];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = processedData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Laptop 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 Local Devices</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
No local devices have communicated with this remote IP in the selected time range.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((dev: any, i: number) => {
|
||||
const appBytes = dev.download + dev.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-indigo-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={dev.app_label}>
|
||||
<span className="truncate text-blue-400 font-mono">{dev.app_label}</span>
|
||||
<span className="text-[10px] font-mono text-indigo-600 dark:text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded-full ring-1 ring-indigo-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(dev.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(dev.upload)}</span>
|
||||
</div>
|
||||
{dev.last_seen && (
|
||||
<div className="flex justify-between mt-2 pt-2 border-t border-border/50">
|
||||
<span className="text-muted-foreground">Last Seen</span>
|
||||
<span className="text-muted-foreground font-medium">{new Date(dev.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface SafeImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
fallbackName?: string;
|
||||
}
|
||||
|
||||
export function SafeImage({ src, fallbackName, ...props }: SafeImageProps) {
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src || src.endsWith("/undefined") || src.endsWith("/null")) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let objectUrl = "";
|
||||
|
||||
fetch(src)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to load image");
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageSrc(objectUrl);
|
||||
setError(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Failed to load profile image gracefully:", err.message);
|
||||
if (active) setError(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
if (error || !imageSrc) {
|
||||
const initials = fallbackName
|
||||
? fallbackName.substring(0, 2).toUpperCase()
|
||||
: "Profile";
|
||||
return (
|
||||
<div
|
||||
className={props.className}
|
||||
style={{
|
||||
backgroundColor: "rgba(30, 41, 59, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={imageSrc} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface TabProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tab({ children }: TabProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
children: React.ReactElement<TabProps>[];
|
||||
}
|
||||
|
||||
export function Tabs({ children }: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex space-x-1 border-b border-white/10 overflow-x-auto pb-1">
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (!React.isValidElement(child)) return null;
|
||||
const isActive = index === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className={`
|
||||
px-4 py-2 text-sm font-medium rounded-t-lg transition-colors whitespace-nowrap
|
||||
${isActive
|
||||
? 'bg-blue-600/20 text-blue-400 border-b-2 border-blue-500'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'}
|
||||
`}
|
||||
>
|
||||
{child.props.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (index !== activeTab) return null;
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { formatTimestampWIB, calculateDuration, getActiveStatus } from "@/lib/utils";
|
||||
|
||||
interface TimestampCellProps {
|
||||
timestamp: string | Date | null | undefined;
|
||||
firstSeen?: string | Date | null | undefined;
|
||||
showActiveStatus?: boolean;
|
||||
}
|
||||
|
||||
export function TimestampCell({ timestamp, firstSeen, showActiveStatus = true }: TimestampCellProps) {
|
||||
if (!timestamp) {
|
||||
return <span className="text-muted-foreground text-xs">-</span>;
|
||||
}
|
||||
|
||||
const formattedTime = formatTimestampWIB(timestamp);
|
||||
const splitTime = formattedTime.split(' '); // toLocaleString('id-ID') separates date and time with a space usually
|
||||
const dateStr = splitTime[0] || formattedTime;
|
||||
const timeStr = splitTime[1] || "";
|
||||
|
||||
const isActive = showActiveStatus ? getActiveStatus(timestamp) : false;
|
||||
const duration = firstSeen ? calculateDuration(firstSeen, timestamp) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 whitespace-nowrap">
|
||||
<span className="text-sm font-medium">{dateStr}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{timeStr}</span>
|
||||
|
||||
{showActiveStatus && (
|
||||
<span className={`text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-full mt-0.5 ${isActive ? "bg-emerald-500/20 text-emerald-400" : "bg-slate-500/20 text-slate-400"}`}>
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{duration && duration !== "-" && (
|
||||
<span className="text-[10px] font-mono text-blue-400/80 mt-0.5" title={`Duration: ${duration}`}>
|
||||
⏱ {duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SlidersHorizontal, X } from "lucide-react";
|
||||
import { DropdownFilterSelect } from "./DropdownFilterSelect";
|
||||
import { DateRangePicker } from "./DateRangePicker";
|
||||
|
||||
export interface FilterConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'select' | 'date-range';
|
||||
value?: string;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
formatter?: (val: string) => string;
|
||||
}
|
||||
|
||||
export interface UniversalFiltersProps {
|
||||
filters: FilterConfig[];
|
||||
onChange: (id: string, value: string) => void;
|
||||
showDateRange?: boolean;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
minDate?: string;
|
||||
onDateChange?: (from: string, to: string) => void;
|
||||
activeFilterCount: number;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function UniversalFilters({
|
||||
filters,
|
||||
onChange,
|
||||
showDateRange = false,
|
||||
dateFrom = "",
|
||||
dateTo = "",
|
||||
minDate = "2024-01-01",
|
||||
onDateChange,
|
||||
activeFilterCount,
|
||||
onReset,
|
||||
}: UniversalFiltersProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
if (filters.length === 0 && !showDateRange) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
{/* Compact Filter Trigger Bar */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center gap-2 px-3.5 py-2 rounded-xl bg-card/90 border border-border/70 hover:border-primary/50 text-xs font-semibold text-foreground transition-all shadow-sm active:scale-[0.98]"
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-primary" />
|
||||
<span>Filter Data</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-primary/20 text-primary px-2 py-0.5 rounded-full text-[10px] font-bold">
|
||||
{activeFilterCount} active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Active Filter Chips summary */}
|
||||
{activeFilterCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{filters.map((f) => {
|
||||
if (f.value && f.value !== "All") {
|
||||
return (
|
||||
<span
|
||||
key={f.id}
|
||||
className="text-[10px] font-semibold bg-primary/10 text-primary border border-primary/20 px-2 py-1 rounded-lg flex items-center gap-1"
|
||||
>
|
||||
{f.label}: {f.formatter ? f.formatter(f.value) : f.value}
|
||||
<button
|
||||
onClick={() => onChange(f.id, "All")}
|
||||
className="hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-[11px] font-semibold text-red-400 hover:text-red-300 transition-colors flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-red-500/10"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Reset All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pop-up Filter Modal Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-overlay-in"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl bg-card/95 border border-border/80 rounded-3xl p-5 sm:p-6 shadow-2xl backdrop-blur-2xl animate-modal-in flex flex-col gap-5 max-h-[85vh] overflow-y-auto custom-scrollbar"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-primary" />
|
||||
<h3 className="text-sm sm:text-base font-bold text-foreground">Filter Telemetry Data</h3>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="text-xs bg-primary/20 text-primary px-2.5 py-0.5 rounded-full font-bold">
|
||||
{activeFilterCount} Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1.5 text-muted-foreground hover:text-foreground rounded-full hover:bg-secondary transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{showDateRange && onDateChange && (
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">
|
||||
Date Range
|
||||
</label>
|
||||
<DateRangePicker
|
||||
minDate={minDate}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onChange={onDateChange}
|
||||
onClear={() => onDateChange("", "")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.map((f) => {
|
||||
if (f.type === 'select') {
|
||||
return (
|
||||
<div key={f.id} className="space-y-1.5">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">
|
||||
{f.label}
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={f.value || "All"}
|
||||
onChange={(val) => onChange(f.id, val)}
|
||||
options={f.options || []}
|
||||
placeholder={
|
||||
f.placeholder ||
|
||||
(f.label.toLowerCase().endsWith('y')
|
||||
? `All ${f.label.slice(0, -1)}ies`
|
||||
: `All ${f.label}s`)
|
||||
}
|
||||
optionFormatter={f.formatter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border/50 pt-4 mt-2">
|
||||
{activeFilterCount > 0 ? (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-xs font-semibold text-red-400 hover:text-red-300 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Reset All Filters
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="px-5 py-2.5 rounded-xl bg-primary text-white text-xs font-bold hover:bg-primary/90 transition-all shadow-md shadow-primary/20"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUniversalFilterState() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
|
||||
const handleFilterChange = (id: string, val: string) => {
|
||||
setFilters(prev => ({ ...prev, [id]: val }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({});
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
};
|
||||
|
||||
const activeCount = Object.values(filters).filter(v => v && v !== "All").length + (dateFrom || dateTo ? 1 : 0);
|
||||
|
||||
return { filters, dateFrom, dateTo, handleFilterChange, setDateFrom, setDateTo, handleReset, activeCount };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo, useState, useEffect } from "react";
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Sphere,
|
||||
Graticule,
|
||||
ZoomableGroup
|
||||
} from "react-simple-maps";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
interface WorldMapProps {
|
||||
data: {
|
||||
countryCode: string;
|
||||
value: number;
|
||||
download?: number;
|
||||
upload?: number;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
const [tooltipData, setTooltipData] = useState<any>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [logBounds, setLogBounds] = useState({ min: 0, max: 1 });
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [position, setPosition] = useState({ coordinates: [0, 30] as [number, number], zoom: 1 });
|
||||
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
const logs = data.filter(d => d.value > 0).map(d => Math.log10(d.value));
|
||||
if (logs.length > 0) {
|
||||
const minLog = Math.min(...logs);
|
||||
const maxLog = Math.max(...logs);
|
||||
setLogBounds({
|
||||
min: minLog,
|
||||
max: maxLog > minLog ? maxLog : minLog + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const { min, max } = logBounds;
|
||||
const step = (max - min) / 4;
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain([min, min + step, min + step * 2, min + step * 3, max])
|
||||
.range(isLight
|
||||
? ["#e0f2fe", "#bae6fd", "#7dd3fc", "#38bdf8", "#0284c7"] // Light Mode: Clean Sky Blues
|
||||
: ["#0f172a", "#1e3a8a", "#1d4ed8", "#2563eb", "#60a5fa"] // Dark Mode: Deep Blues
|
||||
);
|
||||
|
||||
const defaultLandColor = isLight ? "#f1f5f9" : "#111827";
|
||||
const oceanColor = isLight ? "#f8fafc" : "transparent";
|
||||
const strokeColor = isLight ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.05)";
|
||||
|
||||
function handleZoomIn() {
|
||||
if (position.zoom >= 4) return;
|
||||
setPosition((pos) => ({ ...pos, zoom: pos.zoom * 1.5 }));
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
if (position.zoom <= 1) return;
|
||||
setPosition((pos) => ({ ...pos, zoom: pos.zoom / 1.5 }));
|
||||
}
|
||||
|
||||
function handleMoveEnd(pos: { coordinates: [number, number]; zoom: number }) {
|
||||
setPosition(pos);
|
||||
}
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full aspect-[2/1] bg-card rounded-xl overflow-hidden border border-border/50 shadow-inner group"
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
}}
|
||||
onMouseLeave={() => setTooltipData(null)}
|
||||
>
|
||||
{/* Legend */}
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-background/80 p-3 rounded-lg border border-border/50 backdrop-blur-md shadow-lg">
|
||||
<div className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
Heatmap Intensity
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="text-xs text-muted-foreground">Low</div>
|
||||
<div
|
||||
className="w-24 h-2 rounded-full shadow-sm"
|
||||
style={{
|
||||
background: isLight
|
||||
? 'linear-gradient(to right, #e0f2fe, #bae6fd, #7dd3fc, #38bdf8, #0284c7)'
|
||||
: 'linear-gradient(to right, #1e3a8a, #1d4ed8, #2563eb, #60a5fa)'
|
||||
}}
|
||||
></div>
|
||||
<div className="text-xs text-muted-foreground">High</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm"
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Floating Mouse Tooltip */}
|
||||
{tooltipData && (
|
||||
<div
|
||||
style={{ top: mousePos.y + 15, left: mousePos.x + 15 }}
|
||||
className="absolute z-50 bg-background/95 backdrop-blur-xl border border-border px-4 py-3 rounded-xl shadow-2xl pointer-events-none animate-in fade-in zoom-in-95 duration-100 min-w-[200px]"
|
||||
>
|
||||
<div className="text-sm font-bold text-foreground mb-2 flex items-center gap-2 border-b border-border/50 pb-2">
|
||||
{tooltipData.label}
|
||||
</div>
|
||||
{tooltipData.value > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="font-semibold text-sky-400">{fmtBytes(tooltipData.download || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="font-semibold text-emerald-400">{fmtBytes(tooltipData.upload || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs pt-1 mt-1 border-t border-border/30">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-bold text-rose-400">{fmtBytes(tooltipData.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground italic">No traffic recorded</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 140,
|
||||
center: [0, 30]
|
||||
}}
|
||||
width={800}
|
||||
height={400}
|
||||
className="w-full h-full outline-none cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<ZoomableGroup
|
||||
zoom={position.zoom}
|
||||
center={position.coordinates}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
maxZoom={6}
|
||||
translateExtent={[[-200, -100], [1000, 600]]}
|
||||
>
|
||||
<Sphere stroke={strokeColor} strokeWidth={0.5} id="sphere" fill={oceanColor} />
|
||||
<Graticule stroke={strokeColor} strokeWidth={0.5} />
|
||||
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => {
|
||||
const d = data.find((s) => {
|
||||
if (!geo.properties.name) return false;
|
||||
const geoName = geo.properties.name.toLowerCase();
|
||||
const sLabel = s.label.toLowerCase();
|
||||
|
||||
return geoName === sLabel ||
|
||||
(sLabel === 'united states' && geoName === 'united states of america') ||
|
||||
(sLabel === 'the netherlands' && geoName === 'netherlands') ||
|
||||
(sLabel === 'south korea' && geoName === 'korea');
|
||||
});
|
||||
|
||||
const hasData = d && d.value > 0;
|
||||
const fillColor = hasData ? colorScale(Math.log10(d.value)) : defaultLandColor;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none", transition: "fill 300ms" },
|
||||
hover: {
|
||||
fill: hasData ? "#38bdf8" : (isLight ? "#e2e8f0" : "#334155"), // Sky-400 glow or fallback
|
||||
stroke: "#ffffff",
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
transition: "all 150ms ease",
|
||||
filter: hasData ? "drop-shadow(0 0 8px rgba(56,189,248,0.6))" : "none"
|
||||
},
|
||||
pressed: { outline: "none" }
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setTooltipData(d ? d : { label: geo.properties.name, value: 0 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorldMap = memo(WorldMapComponent);
|
||||
@@ -0,0 +1,183 @@
|
||||
// src/components/ui/globeOffsets.ts
|
||||
|
||||
export const SMALL_LABEL_COUNTRIES = new Set([
|
||||
// Europe
|
||||
'GB', 'IE', 'FR', 'DE', 'NL', 'BE', 'LU', 'CH', 'IT', 'ES', 'PT', 'GR', 'TR', 'CY',
|
||||
'NO', 'SE', 'FI', 'DK', 'PL', 'UA', 'BY', 'LT', 'LV', 'EE', 'RO', 'BG', 'HU', 'SK', 'CZ', 'AT', 'MD',
|
||||
// Central America & Caribbean
|
||||
'GP', 'TT', 'PA', 'CR', 'NI', 'HN', 'SV', 'GT', 'BZ', 'JM', 'PR', 'DO', 'HT', 'BS', 'VG', 'DM',
|
||||
// Indian Ocean
|
||||
'MU', 'RE', 'SC'
|
||||
]);
|
||||
|
||||
export function getStaggeredCoordinates(countryCode: string, startLat: number, startLng: number): { lat: number; lng: number } {
|
||||
let lat = startLat;
|
||||
let lng = startLng;
|
||||
|
||||
if (countryCode === 'SG') {
|
||||
lat += 1.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'MY') {
|
||||
lat += 3.0;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'VE') {
|
||||
lat += 2.5;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'CO') {
|
||||
lat += 1.0;
|
||||
lng -= 3.0;
|
||||
} else if (countryCode === 'EC') {
|
||||
lat -= 0.5;
|
||||
lng -= 3.5;
|
||||
} else if (countryCode === 'PE') {
|
||||
lat -= 1.0;
|
||||
lng -= 2.5;
|
||||
} else if (countryCode === 'BO') {
|
||||
lat -= 1.5;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'PY') {
|
||||
lat -= 1.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'UY') {
|
||||
lat -= 2.0;
|
||||
lng += 3.0;
|
||||
} else if (countryCode === 'BR') {
|
||||
lat += 1.5;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'CL') {
|
||||
lat -= 3.0;
|
||||
lng -= 2.0;
|
||||
} else if (countryCode === 'AR') {
|
||||
lat -= 3.5;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'SR') {
|
||||
lat += 1.5;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'GY') {
|
||||
lat += 2.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'PA') {
|
||||
lat -= 1.0;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'GT') {
|
||||
lat += 2.5;
|
||||
lng -= 2.0;
|
||||
} else if (countryCode === 'TT') {
|
||||
lat -= 1.5;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'GP') {
|
||||
lat += 3.0;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'PR') {
|
||||
lat += 1.5;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'VG') {
|
||||
lat += 2.2;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'DO') {
|
||||
lat -= 1.0;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'HT') {
|
||||
lat -= 1.5;
|
||||
lng -= 2.5;
|
||||
} else if (countryCode === 'JM') {
|
||||
lat -= 2.0;
|
||||
lng -= 4.0;
|
||||
} else if (countryCode === 'CR') {
|
||||
lat -= 0.5;
|
||||
lng -= 2.0;
|
||||
} else if (countryCode === 'NI') {
|
||||
lat += 0.5;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'HN') {
|
||||
lat += 1.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'SV') {
|
||||
lat -= 1.5;
|
||||
lng -= 2.5;
|
||||
} else if (countryCode === 'MU') {
|
||||
lat += 0.8;
|
||||
lng += 0.8;
|
||||
} else if (countryCode === 'RE') {
|
||||
lat -= 0.8;
|
||||
lng -= 0.8;
|
||||
} else if (countryCode === 'NO') {
|
||||
lat += 2.0;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'SE') {
|
||||
lat += 3.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'FI') {
|
||||
lat += 4.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'DK') {
|
||||
lat += 0.8;
|
||||
} else if (countryCode === 'NL') {
|
||||
lat += 1.5;
|
||||
lng += 0.5;
|
||||
} else if (countryCode === 'BE') {
|
||||
lat -= 0.5;
|
||||
lng -= 1.2;
|
||||
} else if (countryCode === 'LU') {
|
||||
lat -= 1.5;
|
||||
lng -= 0.8;
|
||||
} else if (countryCode === 'CH') {
|
||||
lat -= 2.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'DE') {
|
||||
lat += 1.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'AT') {
|
||||
lat -= 1.5;
|
||||
lng -= 0.5;
|
||||
} else if (countryCode === 'IT') {
|
||||
lat -= 4.0;
|
||||
lng -= 0.5;
|
||||
} else if (countryCode === 'ES') {
|
||||
lat -= 1.5;
|
||||
} else if (countryCode === 'PT') {
|
||||
lat -= 1.0;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'GR') {
|
||||
lat -= 4.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'EE') {
|
||||
lat += 2.0;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'LV') {
|
||||
lat += 1.2;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'LT') {
|
||||
lat += 0.5;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'PL') {
|
||||
lat += 1.0;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'UA') {
|
||||
lat += 0.5;
|
||||
lng += 3.0;
|
||||
} else if (countryCode === 'MD') {
|
||||
lat -= 0.2;
|
||||
lng += 2.5;
|
||||
} else if (countryCode === 'BY') {
|
||||
lat += 2.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'RO') {
|
||||
lat -= 1.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'BG') {
|
||||
lat -= 2.5;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'HU') {
|
||||
lat -= 2.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'SK') {
|
||||
lat -= 1.2;
|
||||
lng += 0.5;
|
||||
} else if (countryCode === 'CZ') {
|
||||
lat -= 0.5;
|
||||
lng -= 1.0;
|
||||
}
|
||||
|
||||
return { lat, lng };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
|
||||
export const sharedStyles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 30,
|
||||
fontSize: 9,
|
||||
fontFamily: "Helvetica",
|
||||
color: "#334155",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1.5,
|
||||
borderBottomColor: "#3b82f6",
|
||||
paddingBottom: 12,
|
||||
marginBottom: 15,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#1e3a8a",
|
||||
},
|
||||
logoSubtext: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
marginTop: 2,
|
||||
},
|
||||
titleContainer: {
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
color: "#0f172a",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
marginTop: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
color: "#1e3a8a",
|
||||
backgroundColor: "#f8fafc",
|
||||
padding: "4 8",
|
||||
marginTop: 15,
|
||||
marginBottom: 8,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: "#3b82f6",
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
marginBottom: 10,
|
||||
},
|
||||
gridCol: {
|
||||
width: "25%",
|
||||
marginBottom: 8,
|
||||
},
|
||||
gridLabel: {
|
||||
fontSize: 7,
|
||||
color: "#64748b",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 2,
|
||||
},
|
||||
gridValue: {
|
||||
fontSize: 8.5,
|
||||
fontWeight: "bold",
|
||||
color: "#0f172a",
|
||||
},
|
||||
bandwidthGrid: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
},
|
||||
bandwidthCardCyan: {
|
||||
width: "48%",
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e2e8f0",
|
||||
backgroundColor: "#f0fdfa",
|
||||
},
|
||||
bandwidthCardGreen: {
|
||||
width: "48%",
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e2e8f0",
|
||||
backgroundColor: "#f0fdf4",
|
||||
},
|
||||
bandwidthLabel: {
|
||||
fontSize: 7.5,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 3,
|
||||
},
|
||||
bandwidthValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
marginBottom: 10,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: "#f1f5f9",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#cbd5e1",
|
||||
padding: "4 6",
|
||||
},
|
||||
tableHeaderCell: {
|
||||
fontSize: 7.5,
|
||||
fontWeight: "bold",
|
||||
color: "#475569",
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f1f5f9",
|
||||
padding: "4 6",
|
||||
},
|
||||
tableCell: {
|
||||
fontSize: 7.5,
|
||||
color: "#334155",
|
||||
},
|
||||
alertBox: {
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#fef2f2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#fecaca",
|
||||
marginBottom: 10,
|
||||
},
|
||||
alertTitle: {
|
||||
fontSize: 8.5,
|
||||
fontWeight: "bold",
|
||||
color: "#991b1b",
|
||||
marginBottom: 2,
|
||||
},
|
||||
alertText: {
|
||||
fontSize: 7.5,
|
||||
color: "#7f1d1d",
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
fontStyle: "italic",
|
||||
padding: 6,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// src/components/ui/useGlobeData.ts
|
||||
import { useMemo } from 'react';
|
||||
import { SMALL_LABEL_COUNTRIES, getStaggeredCoordinates } from './globeOffsets';
|
||||
|
||||
export interface GlobeDataInput {
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
endLat: number;
|
||||
endLng: number;
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
value: number;
|
||||
download: number;
|
||||
upload: number;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
export function useGlobeData(data: GlobeDataInput[], isLight: boolean) {
|
||||
const arcsData = useMemo(() => {
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
|
||||
}));
|
||||
}, [data, isLight]);
|
||||
|
||||
const pointsData = useMemo(() => {
|
||||
const points: any[] = [];
|
||||
const originSet = new Set<string>();
|
||||
const destSet = new Set<string>();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
points.push({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
size: 0.1,
|
||||
color: isLight ? '#0284c7' : '#38bdf8',
|
||||
name: arc.toLabel,
|
||||
value: arc.value,
|
||||
upload: arc.upload,
|
||||
download: arc.download,
|
||||
isOrigin: false
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
points.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
size: 0.2,
|
||||
color: '#f43f5e',
|
||||
name: arc.fromLabel,
|
||||
value: 0,
|
||||
upload: 0,
|
||||
download: 0,
|
||||
isOrigin: true
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
|
||||
return points;
|
||||
}, [arcsData, isLight]);
|
||||
|
||||
const labelsData = useMemo(() => {
|
||||
const labels: any[] = [];
|
||||
const originSet = new Set();
|
||||
const destSet = new Set();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
// Shift and stagger coordinates (Southeast Asia, Europe, Caribbean, Indian Ocean, Latin America)
|
||||
const { lat, lng } = getStaggeredCoordinates(arc.countryCode || '', arc.endLat, arc.endLng);
|
||||
const size = SMALL_LABEL_COUNTRIES.has(arc.countryCode || '') ? 0.9 : 1.4;
|
||||
|
||||
labels.push({
|
||||
lat,
|
||||
lng,
|
||||
name: arc.toLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc',
|
||||
size
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
labels.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
name: arc.fromLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc',
|
||||
size: 1.4
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
}, [arcsData, isLight]);
|
||||
|
||||
return { arcsData, pointsData, labelsData };
|
||||
}
|
||||
Reference in new issue
Block a user