v1.1: Add CIDR subnet filtering and Agent filter in Devices page

This commit is contained in:
ypratama committed 2026-08-27 12:02:01 +07:00
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>
);
}
+54
View File
@@ -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>
);
}
+234
View File
@@ -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)}
/>
)}
</>
);
}
+155
View File
@@ -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>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { Pagination } from "./Pagination";
interface Props {
events: any[];
}
function severityStyle(sev: string | null) {
switch ((sev || "").toLowerCase()) {
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
case "high": return { badge: "bg-orange-500/15 text-orange-400 ring-orange-500/30", dot: "bg-orange-400" };
case "warning": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
case "info": return { badge: "bg-blue-500/15 text-blue-400 ring-blue-500/30", dot: "bg-blue-400" };
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
}
}
export default function AgentEventsTab({ events }: Props) {
const [page, setPage] = useState(1);
const pageSize = 50;
const totalPages = Math.ceil(events.length / pageSize);
const startIndex = (page - 1) * pageSize;
const paginated = events.slice(startIndex, startIndex + pageSize);
return (
<div className="space-y-3">
<div className="space-y-2">
{paginated.map((ev, i) => {
const sc = severityStyle(ev.severity);
return (
<div
key={i}
className={`p-3 border rounded-xl text-xs transition-colors ${
(ev.severity || "").toLowerCase() === "critical"
? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10"
: (ev.severity || "").toLowerCase() === "high"
? "bg-orange-500/5 border-orange-500/20 hover:bg-orange-500/10"
: "bg-white/[0.03] border-border/50 hover:bg-white/[0.06]"
}`}
>
<div className="flex items-start gap-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 flex-shrink-0 ${sc.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
{ev.severity || "—"}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="font-semibold text-card-foreground">{ev.event_type || "Unknown"}</span>
<span className="font-mono text-blue-400">{ev.ip_address || "—"}</span>
{ev.mac_address && <span className="font-mono text-muted-foreground">{ev.mac_address}</span>}
</div>
{ev.description && <p className="text-muted-foreground leading-relaxed">{ev.description}</p>}
</div>
<span className="text-slate-600 flex-shrink-0 truncate">
{ev.event_at ? ev.event_at.slice(0, 16).replace("T", " ") : "—"}
</span>
</div>
</div>
);
})}
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
onPageChange={setPage}
totalResults={events.length}
startIndex={startIndex}
endIndex={startIndex + pageSize}
/>
</div>
);
}
+173
View File
@@ -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>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { Wifi } from "lucide-react";
import { fmtBytes } from "@/lib/utils";
import { Pagination } from "./Pagination";
interface Props {
macBandwidth: any[];
}
export default function AgentMacTab({ macBandwidth }: Props) {
const [page, setPage] = useState(1);
const pageSize = 50;
const totalPages = Math.ceil(macBandwidth.length / pageSize);
const startIndex = (page - 1) * pageSize;
const paginated = macBandwidth.slice(startIndex, startIndex + pageSize);
return (
<div className="space-y-3">
<div className="space-y-2">
{paginated.map((m, i) => {
const maxTotal = 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>
);
}
+245
View File
@@ -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>
);
}
+166
View File
@@ -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>
);
}
+203
View File
@@ -0,0 +1,203 @@
// src/components/ui/AgentTelemetryTab.tsx
"use client";
import { useTimeline } from "@/lib/api-with-context";
import { Loader2 } from "lucide-react";
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend
} from "recharts";
interface Props {
agentUuid: string;
}
export default function AgentTelemetryTab({ agentUuid }: Props) {
const { data, isLoading } = useTimeline(60, agentUuid);
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
if (!data || data.length === 0) {
return (
<div className="text-center py-20 text-muted-foreground text-sm">
No telemetry data recorded for this agent in the last 24 hours.
</div>
);
}
// Format time labels for XAxis
const chartData = data.map(pt => {
const date = new Date(pt.fetched_at);
const dateStr = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
const timeStr = date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
const xAxisLabel = `${dateStr} ${timeStr}`;
const rawFullLabel = date.toLocaleString('en-GB', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
const fullLabel = rawFullLabel.replace(' at ', ', ') + ' WIB';
return {
...pt,
xAxisLabel,
fullLabel,
flows: pt.total_flows ?? 0,
peak: pt.peak_flow_rate ?? 0,
drops: pt.packet_drops ?? 0,
cpu: pt.cpu_usage ?? 0,
memory: pt.memory_usage ?? 0,
queue: pt.queue_depth ?? 0
};
});
return (
<div className="space-y-8 p-4">
{/* Flow Rates Chart */}
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-blue-400">Flow Rates & Telemetry Peak</h4>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="flowsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
<linearGradient id="peakGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
<YAxis stroke="#64748b" fontSize={11} />
<Tooltip
labelFormatter={(value) => {
const matched = chartData.find(item => item.xAxisLabel === value);
return matched ? matched.fullLabel : value;
}}
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
/>
<Legend verticalAlign="top" height={36} />
<Area type="monotone" dataKey="flows" name="Active Flows" stroke="#3b82f6" fillOpacity={1} fill="url(#flowsGrad)" strokeWidth={2} />
<Area type="monotone" dataKey="peak" name="Peak Flow Rate" stroke="#8b5cf6" fillOpacity={1} fill="url(#peakGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Packet Drops Chart */}
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-rose-400">Packet Drop Rate Trend</h4>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="dropsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
<YAxis stroke="#64748b" fontSize={11} />
<Tooltip
labelFormatter={(value) => {
const matched = chartData.find(item => item.xAxisLabel === value);
return matched ? matched.fullLabel : value;
}}
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
/>
<Legend verticalAlign="top" height={36} />
<Area type="monotone" dataKey="drops" name="Packet Drops (pkts/sec)" stroke="#f43f5e" fillOpacity={1} fill="url(#dropsGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Agent Resource Telemetry (CPU & Memory) */}
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-emerald-400">Agent Resource Telemetry (CPU & RAM)</h4>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
</linearGradient>
<linearGradient id="memGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
<YAxis stroke="#64748b" fontSize={11} unit="%" />
<Tooltip
labelFormatter={(value) => {
const matched = chartData.find(item => item.xAxisLabel === value);
return matched ? matched.fullLabel : value;
}}
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
/>
<Legend verticalAlign="top" height={36} />
<Area type="monotone" dataKey="cpu" name="CPU Usage (%)" stroke="#10b981" fillOpacity={1} fill="url(#cpuGrad)" strokeWidth={2} />
<Area type="monotone" dataKey="memory" name="Memory Usage (%)" stroke="#f59e0b" fillOpacity={1} fill="url(#memGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Network Queue Depth */}
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-cyan-400">Network Packet Queue Depth Trend</h4>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="queueGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
<YAxis stroke="#64748b" fontSize={11} />
<Tooltip
labelFormatter={(value) => {
const matched = chartData.find(item => item.xAxisLabel === value);
return matched ? matched.fullLabel : value;
}}
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
/>
<Legend verticalAlign="top" height={36} />
<Area type="monotone" dataKey="queue" name="Queue Depth (packets)" stroke="#06b6d4" fillOpacity={1} fill="url(#queueGrad)" strokeWidth={2} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
+215
View File
@@ -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)}
/>
)}
</>
);
}
+164
View File
@@ -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>
);
}
+26
View File
@@ -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}
/>
);
}
+68
View File
@@ -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>
);
}
+194
View File
@@ -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>
);
}
+25
View File
@@ -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);
}
+115
View File
@@ -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>
);
}
+176
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+147
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+239
View File
@@ -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>
);
}
+132
View File
@@ -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>
);
}
+159
View File
@@ -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>
);
}
+54
View File
@@ -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>
);
}
+34
View File
@@ -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>
);
}
+65
View File
@@ -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>
);
}
+228
View File
@@ -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>
);
}
+153
View File
@@ -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,
},
});
+132
View File
@@ -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>
);
}
+157
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+152
View File
@@ -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>
);
};
+38
View File
@@ -0,0 +1,38 @@
import { fmtBytes } from "@/lib/utils";
export const getArcTooltip = (d: any): string => `
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: 180px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #facc15;">
${d.fromLabel}
</div>
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #38bdf8;">
${d.toLabel}
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
</div>
</div>
`;
export const getPointTooltip = (d: any): string => `
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: ${d.value > 0 ? '180px' : 'auto'}; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<div style="font-weight: 600; font-size: 14px; ${d.value > 0 ? 'border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px;' : ''}">${d.name}</div>
${d.value > 0 ? `
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
</div>
` : ''}
</div>
`;
+149
View File
@@ -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} &copy; 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>
</>
);
}
+68
View File
@@ -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>
);
}
+82
View File
@@ -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
);
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
totalResults: number;
startIndex: number;
endIndex: number;
}
export function Pagination({
currentPage,
totalPages,
onPageChange,
totalResults,
startIndex,
endIndex,
}: PaginationProps) {
if (totalPages <= 1) return null;
return (
<div className="flex items-center justify-between px-3 py-3 border-t border-border/40 bg-white/[0.01] rounded-b-xl text-xs flex-wrap gap-2">
<div className="text-muted-foreground">
Showing <span className="font-medium text-slate-300">{startIndex + 1}</span> to{" "}
<span className="font-medium text-slate-300">{Math.min(endIndex, totalResults)}</span> of{" "}
<span className="font-medium text-slate-300">{totalResults}</span> results
</div>
<div className="flex items-center gap-1">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="p-1 rounded bg-secondary hover:bg-secondary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-all border border-border/50 text-slate-300"
title="Previous Page"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="text-muted-foreground font-medium px-2">
Page {currentPage} of {totalPages}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-1 rounded bg-secondary hover:bg-secondary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-all border border-border/50 text-slate-300"
title="Next Page"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
);
}
+231
View File
@@ -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>
);
}
+69
View File
@@ -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} />;
}
+49
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+217
View File
@@ -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 };
}
+232
View File
@@ -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);
+183
View File
@@ -0,0 +1,183 @@
// src/components/ui/globeOffsets.ts
export const SMALL_LABEL_COUNTRIES = new Set([
// Europe
'GB', 'IE', 'FR', 'DE', 'NL', 'BE', 'LU', 'CH', 'IT', 'ES', 'PT', 'GR', 'TR', 'CY',
'NO', 'SE', 'FI', 'DK', 'PL', 'UA', 'BY', 'LT', 'LV', 'EE', 'RO', 'BG', 'HU', 'SK', 'CZ', 'AT', 'MD',
// Central America & Caribbean
'GP', 'TT', 'PA', 'CR', 'NI', 'HN', 'SV', 'GT', 'BZ', 'JM', 'PR', 'DO', 'HT', 'BS', 'VG', 'DM',
// Indian Ocean
'MU', 'RE', 'SC'
]);
export function getStaggeredCoordinates(countryCode: string, startLat: number, startLng: number): { lat: number; lng: number } {
let lat = startLat;
let lng = startLng;
if (countryCode === 'SG') {
lat += 1.5;
lng -= 1.0;
} else if (countryCode === 'MY') {
lat += 3.0;
lng -= 1.5;
} else if (countryCode === 'VE') {
lat += 2.5;
lng += 1.0;
} else if (countryCode === 'CO') {
lat += 1.0;
lng -= 3.0;
} else if (countryCode === 'EC') {
lat -= 0.5;
lng -= 3.5;
} else if (countryCode === 'PE') {
lat -= 1.0;
lng -= 2.5;
} else if (countryCode === 'BO') {
lat -= 1.5;
lng -= 1.5;
} else if (countryCode === 'PY') {
lat -= 1.0;
lng += 2.0;
} else if (countryCode === 'UY') {
lat -= 2.0;
lng += 3.0;
} else if (countryCode === 'BR') {
lat += 1.5;
lng += 2.0;
} else if (countryCode === 'CL') {
lat -= 3.0;
lng -= 2.0;
} else if (countryCode === 'AR') {
lat -= 3.5;
lng += 1.0;
} else if (countryCode === 'SR') {
lat += 1.5;
lng += 2.0;
} else if (countryCode === 'GY') {
lat += 2.5;
lng -= 1.0;
} else if (countryCode === 'PA') {
lat -= 1.0;
lng -= 1.0;
} else if (countryCode === 'GT') {
lat += 2.5;
lng -= 2.0;
} else if (countryCode === 'TT') {
lat -= 1.5;
lng += 1.5;
} else if (countryCode === 'GP') {
lat += 3.0;
lng += 1.5;
} else if (countryCode === 'PR') {
lat += 1.5;
lng -= 1.5;
} else if (countryCode === 'VG') {
lat += 2.2;
lng += 1.0;
} else if (countryCode === 'DO') {
lat -= 1.0;
lng -= 1.0;
} else if (countryCode === 'HT') {
lat -= 1.5;
lng -= 2.5;
} else if (countryCode === 'JM') {
lat -= 2.0;
lng -= 4.0;
} else if (countryCode === 'CR') {
lat -= 0.5;
lng -= 2.0;
} else if (countryCode === 'NI') {
lat += 0.5;
lng -= 1.5;
} else if (countryCode === 'HN') {
lat += 1.5;
lng -= 1.0;
} else if (countryCode === 'SV') {
lat -= 1.5;
lng -= 2.5;
} else if (countryCode === 'MU') {
lat += 0.8;
lng += 0.8;
} else if (countryCode === 'RE') {
lat -= 0.8;
lng -= 0.8;
} else if (countryCode === 'NO') {
lat += 2.0;
lng -= 1.0;
} else if (countryCode === 'SE') {
lat += 3.0;
lng += 1.0;
} else if (countryCode === 'FI') {
lat += 4.0;
lng += 2.0;
} else if (countryCode === 'DK') {
lat += 0.8;
} else if (countryCode === 'NL') {
lat += 1.5;
lng += 0.5;
} else if (countryCode === 'BE') {
lat -= 0.5;
lng -= 1.2;
} else if (countryCode === 'LU') {
lat -= 1.5;
lng -= 0.8;
} else if (countryCode === 'CH') {
lat -= 2.5;
lng -= 1.0;
} else if (countryCode === 'DE') {
lat += 1.0;
lng += 1.0;
} else if (countryCode === 'AT') {
lat -= 1.5;
lng -= 0.5;
} else if (countryCode === 'IT') {
lat -= 4.0;
lng -= 0.5;
} else if (countryCode === 'ES') {
lat -= 1.5;
} else if (countryCode === 'PT') {
lat -= 1.0;
lng -= 1.5;
} else if (countryCode === 'GR') {
lat -= 4.0;
lng += 1.0;
} else if (countryCode === 'EE') {
lat += 2.0;
lng += 1.5;
} else if (countryCode === 'LV') {
lat += 1.2;
lng += 1.5;
} else if (countryCode === 'LT') {
lat += 0.5;
lng += 1.5;
} else if (countryCode === 'PL') {
lat += 1.0;
lng += 1.5;
} else if (countryCode === 'UA') {
lat += 0.5;
lng += 3.0;
} else if (countryCode === 'MD') {
lat -= 0.2;
lng += 2.5;
} else if (countryCode === 'BY') {
lat += 2.0;
lng += 2.0;
} else if (countryCode === 'RO') {
lat -= 1.0;
lng += 2.0;
} else if (countryCode === 'BG') {
lat -= 2.5;
lng += 2.0;
} else if (countryCode === 'HU') {
lat -= 2.0;
lng += 1.0;
} else if (countryCode === 'SK') {
lat -= 1.2;
lng += 0.5;
} else if (countryCode === 'CZ') {
lat -= 0.5;
lng -= 1.0;
}
return { lat, lng };
}
+153
View File
@@ -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,
},
});
+109
View File
@@ -0,0 +1,109 @@
// src/components/ui/useGlobeData.ts
import { useMemo } from 'react';
import { SMALL_LABEL_COUNTRIES, getStaggeredCoordinates } from './globeOffsets';
export interface GlobeDataInput {
startLat: number;
startLng: number;
endLat: number;
endLng: number;
fromLabel: string;
toLabel: string;
value: number;
download: number;
upload: number;
countryCode?: string;
}
export function useGlobeData(data: GlobeDataInput[], isLight: boolean) {
const arcsData = useMemo(() => {
return data.map(d => ({
...d,
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
}));
}, [data, isLight]);
const pointsData = useMemo(() => {
const points: any[] = [];
const originSet = new Set<string>();
const destSet = new Set<string>();
arcsData.forEach(arc => {
const originKey = `${arc.startLat},${arc.startLng}`;
const destKey = `${arc.endLat},${arc.endLng}`;
if (!destSet.has(destKey)) {
points.push({
lat: arc.endLat,
lng: arc.endLng,
size: 0.1,
color: isLight ? '#0284c7' : '#38bdf8',
name: arc.toLabel,
value: arc.value,
upload: arc.upload,
download: arc.download,
isOrigin: false
});
destSet.add(destKey);
}
if (!originSet.has(originKey)) {
points.push({
lat: arc.startLat,
lng: arc.startLng,
size: 0.2,
color: '#f43f5e',
name: arc.fromLabel,
value: 0,
upload: 0,
download: 0,
isOrigin: true
});
originSet.add(originKey);
}
});
return points;
}, [arcsData, isLight]);
const labelsData = useMemo(() => {
const labels: any[] = [];
const originSet = new Set();
const destSet = new Set();
arcsData.forEach(arc => {
const originKey = `${arc.startLat},${arc.startLng}`;
const destKey = `${arc.endLat},${arc.endLng}`;
if (!destSet.has(destKey)) {
// Shift and stagger coordinates (Southeast Asia, Europe, Caribbean, Indian Ocean, Latin America)
const { lat, lng } = getStaggeredCoordinates(arc.countryCode || '', arc.endLat, arc.endLng);
const size = SMALL_LABEL_COUNTRIES.has(arc.countryCode || '') ? 0.9 : 1.4;
labels.push({
lat,
lng,
name: arc.toLabel,
color: isLight ? '#0f172a' : '#f8fafc',
size
});
destSet.add(destKey);
}
if (!originSet.has(originKey)) {
labels.push({
lat: arc.startLat,
lng: arc.startLng,
name: arc.fromLabel,
color: isLight ? '#0f172a' : '#f8fafc',
size: 1.4
});
originSet.add(originKey);
}
});
return labels;
}, [arcsData, isLight]);
return { arcsData, pointsData, labelsData };
}