feat: complete deployment codebase for BackOne DPI dashboard with dynamic telemetry, rebranding, database size tracking, and modular design
This commit is contained in:
1 parent
6cf3c6c7e5
commit
c316f3171b
226 files changed
+21152
-8397
No files matched your search
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
import { Agent } from "@/lib/actions/agents";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
|
||||
import { Loader2, Trash2, CheckCircle2, XCircle, ChevronRight, UserCog, Eye } from "lucide-react";
|
||||
|
||||
interface GetColumnsProps {
|
||||
role: string | null;
|
||||
uptimeMap: Record<string, number>;
|
||||
storageMap?: Record<string, number>;
|
||||
managedUsers: ManagedUser[];
|
||||
viewAsLoading: string | null;
|
||||
setDetailAgent: (agent: Agent | null) => void;
|
||||
setAccountModalOpen: (open: boolean) => void;
|
||||
setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void;
|
||||
handleViewAs: (uuid: string, label: string) => void;
|
||||
openDelete: (agent: Agent) => void;
|
||||
}
|
||||
|
||||
export function getAgentColumns({
|
||||
role,
|
||||
uptimeMap,
|
||||
storageMap = {},
|
||||
managedUsers,
|
||||
viewAsLoading,
|
||||
setDetailAgent,
|
||||
setAccountModalOpen,
|
||||
setAccountModalAgent,
|
||||
handleViewAs,
|
||||
openDelete,
|
||||
}: GetColumnsProps): Column<Agent>[] {
|
||||
const cols: Column<Agent>[] = [
|
||||
{
|
||||
header: "UUID / Serial",
|
||||
className: "w-[16%] text-center",
|
||||
accessor: (row) => (
|
||||
<button onClick={() => setDetailAgent(row)} className="flex items-center justify-center gap-2 group text-center mx-auto" title="Click to view agent details">
|
||||
<span className="font-mono text-xs text-blue-400 group-hover:text-blue-300 transition-colors">{row.uuid || row.serial}</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||
</button>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Label",
|
||||
className: "w-[24%] text-center",
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||
const hasAccount = managedUsers.some(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER');
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mx-auto max-w-full">
|
||||
<button onClick={() => setDetailAgent(row)} className="text-center hover:text-white transition-colors truncate">
|
||||
{dynamicLabel !== uuid ? dynamicLabel : (row.label || <span className="text-muted-foreground italic font-mono text-xs">{uuid}</span>)}
|
||||
</button>
|
||||
{!hasAccount && (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||
<Badge variant="orange" className="text-[10px] px-1.5 py-0.5 font-semibold shrink-0 whitespace-nowrap">No account yet</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Provisioned",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
|
||||
},
|
||||
{
|
||||
header: "Activated",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
className: "w-[11%] text-center",
|
||||
accessor: (row) => {
|
||||
const statusText = row.last_seen_at?.human || '-';
|
||||
if (statusText === 'Online') {
|
||||
return <span className="text-emerald-400 font-medium flex items-center justify-center gap-1.5"><div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div> Online</span>;
|
||||
} else if (statusText === 'Offline') {
|
||||
return <span className="text-red-400 font-medium">Offline</span>;
|
||||
} else {
|
||||
return <span className="text-slate-400 text-xs">{statusText}</span>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Historical Uptime",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
const uptime = uptimeMap[uuid] ?? 100;
|
||||
let color = "text-emerald-400";
|
||||
if (uptime < 90) color = "text-red-400";
|
||||
else if (uptime < 98) color = "text-amber-400";
|
||||
return <span className={`font-mono font-bold ${color}`}>{uptime.toFixed(1)}%</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
className: "w-[13%] text-center",
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||
const existingUser = managedUsers.find(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER') || null;
|
||||
const isViewAsLoading = viewAsLoading === uuid;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<button onClick={() => setDetailAgent(row)} className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors" title="Detail Agent">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }}
|
||||
className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors"
|
||||
title={existingUser ? 'Edit Agent Account' : 'Create Agent Account'}
|
||||
>
|
||||
<UserCog className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
|
||||
disabled={isViewAsLoading}
|
||||
className="p-1.5 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50"
|
||||
title="View As Agent"
|
||||
>
|
||||
{isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors" title="Delete">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (role === 'SUPER_ADMIN') {
|
||||
cols.splice(cols.length - 1, 0, {
|
||||
header: "Data Size",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
const sizeMB = storageMap[uuid] ?? 0;
|
||||
return <span className="font-mono text-slate-300 font-semibold">{sizeMB.toFixed(2)} MB</span>;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}
|
||||
+144
-252
@@ -1,47 +1,65 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getAgents, createAgent, deleteAgent, Agent } from "@/lib/actions/agents";
|
||||
import { getAgents, Agent } from "@/lib/actions/agents";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
|
||||
import AgentAccountModal from "@/components/admin/AgentAccountModal";
|
||||
import { Loader2, Plus, Trash2, Server, CheckCircle2, XCircle, ChevronRight, UserCog, Eye } from "lucide-react";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { Loader2, Plus, Server, CheckCircle2, XCircle, Activity } from "lucide-react";
|
||||
|
||||
import { getAdminUsers, startViewAs, resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
|
||||
import { getAdminUsers, startViewAs, resolveAgentLabel, getViewAsHeaders, type ManagedUser } from "@/lib/admin-api";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { AgentModals } from "@/components/admin/AgentModals";
|
||||
import { getAgentColumns } from "./columns";
|
||||
|
||||
export default function AgentsPage() {
|
||||
const router = useRouter();
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [uptimeMap, setUptimeMap] = useState<Record<string, number>>({});
|
||||
const [storageMap, setStorageMap] = useState<Record<string, number>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Modals state
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
// Detail modal
|
||||
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
|
||||
|
||||
// Admin: managed users (for label resolution + account management)
|
||||
const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]);
|
||||
|
||||
// Admin: AgentAccountModal state
|
||||
const [accountModalOpen, setAccountModalOpen] = useState(false);
|
||||
const [accountModalAgent, setAccountModalAgent] = useState<{ uuid: string; label: string } | null>(null);
|
||||
|
||||
// View-as loading state
|
||||
const [viewAsLoading, setViewAsLoading] = useState<string | null>(null);
|
||||
|
||||
// Form state
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
const [formData, setFormData] = useState({ agentId: "", label: "" });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { timeRange } = useTimeFilter();
|
||||
|
||||
const loadUptime = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/agents/uptime?timeRange=${timeRange}`, {
|
||||
headers: getViewAsHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.ok && json.uptime) setUptimeMap(json.uptime);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load uptime stats:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStorage = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/agents/storage', {
|
||||
headers: getViewAsHeaders()
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.ok && json.storage) setStorageMap(json.storage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load agent storage stats:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAgents = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -53,6 +71,7 @@ export default function AgentsPage() {
|
||||
if (agentData.status === 'fulfilled') setAgents(agentData.value);
|
||||
else setError((agentData.reason as Error).message);
|
||||
if (userData.status === 'fulfilled') setManagedUsers(userData.value);
|
||||
await loadUptime();
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
}
|
||||
@@ -73,7 +92,7 @@ export default function AgentsPage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
@@ -93,266 +112,139 @@ export default function AgentsPage() {
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
const openCreate = () => {
|
||||
setFormData({ agentId: "", label: "" });
|
||||
setIsCreateOpen(true);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (role && role !== 'AGENT_VIEWER') loadUptime();
|
||||
}, [timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (role === 'SUPER_ADMIN') {
|
||||
loadStorage();
|
||||
}
|
||||
}, [role]);
|
||||
|
||||
const openDelete = (agent: Agent) => {
|
||||
setSelectedAgent(agent);
|
||||
setIsDeleteOpen(true);
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const res = await createAgent(formData.agentId, formData.label);
|
||||
setIsSubmitting(false);
|
||||
if (res.success) {
|
||||
setIsCreateOpen(false);
|
||||
loadAgents();
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
};
|
||||
const columns = getAgentColumns({
|
||||
role,
|
||||
uptimeMap,
|
||||
storageMap,
|
||||
managedUsers,
|
||||
viewAsLoading,
|
||||
setDetailAgent,
|
||||
setAccountModalOpen,
|
||||
setAccountModalAgent,
|
||||
handleViewAs,
|
||||
openDelete,
|
||||
});
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedAgent) return;
|
||||
setIsSubmitting(true);
|
||||
const res = await deleteAgent(selectedAgent.uuid || selectedAgent.serial);
|
||||
setIsSubmitting(false);
|
||||
if (res.success) {
|
||||
setIsDeleteOpen(false);
|
||||
loadAgents();
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Column<Agent>[] = [
|
||||
{
|
||||
header: "UUID / Serial",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setDetailAgent(row)}
|
||||
className="flex items-center gap-2 group text-left"
|
||||
title="Click to view agent details"
|
||||
>
|
||||
<span className="font-mono text-xs text-blue-400 group-hover:text-blue-300 transition-colors">{row.uuid || row.serial}</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||
</button>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Label",
|
||||
accessor: (row) => {
|
||||
// Ambil label dari account_name user yang terhubung ke agent ini
|
||||
const uuid = row.uuid || row.serial;
|
||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||
const hasAccount = managedUsers.some(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER');
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setDetailAgent(row)} className="text-left hover:text-white transition-colors">
|
||||
{dynamicLabel !== uuid ? dynamicLabel : (row.label || <span className="text-muted-foreground italic font-mono text-xs">{uuid}</span>)}
|
||||
</button>
|
||||
{!hasAccount && role === 'SUPER_ADMIN' && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-900/40 border border-amber-700/50 text-amber-400">Belum ada akun</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ header: "Provisioned", accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> },
|
||||
{ header: "Activated", accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> },
|
||||
{
|
||||
header: "Status",
|
||||
accessor: (row) => {
|
||||
const statusText = row.last_seen_at?.human || '-';
|
||||
if (statusText === 'Online') {
|
||||
return <span className="text-emerald-400 font-medium flex items-center gap-1.5"><div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div> Online</span>;
|
||||
} else if (statusText === 'Offline') {
|
||||
return <span className="text-red-400 font-medium">Offline</span>;
|
||||
} else {
|
||||
return <span className="text-slate-400 text-xs">{statusText}</span>; // Last Seen ...
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||
const existingUser = managedUsers.find(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER') || null;
|
||||
const isViewAsLoading = viewAsLoading === uuid;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setDetailAgent(row)}
|
||||
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors"
|
||||
title="Detail Agent"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
{role === 'SUPER_ADMIN' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }}
|
||||
className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors"
|
||||
title={existingUser ? 'Edit Akun Agent' : 'Buat Akun Agent'}
|
||||
>
|
||||
<UserCog className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
|
||||
disabled={isViewAsLoading}
|
||||
className="p-1.5 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50"
|
||||
title="Lihat Sebagai Agent Ini"
|
||||
>
|
||||
{isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors" title="Delete">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
const totalAgents = agents.length;
|
||||
const onlineAgents = agents.filter(a => a.last_seen_at?.human === 'Online').length;
|
||||
const offlineAgents = totalAgents - onlineAgents;
|
||||
const uptimeValues = agents.map(a => uptimeMap[a.uuid || a.serial] ?? 100);
|
||||
const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<Server className="w-6 h-6 text-primary" />
|
||||
Agents Inventory
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
<button
|
||||
onClick={openCreate}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Provision Agent
|
||||
</button>
|
||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||
<button
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-blue-500/10"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Provision Agent
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historical Uptime Status Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-xs font-semibold">
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Server className="w-12 h-12 text-blue-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Total Agents</span>
|
||||
<span className="text-2xl font-black text-white tracking-tight">{totalAgents}</span>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<CheckCircle2 className="w-12 h-12 text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Online Agents</span>
|
||||
<span className="text-2xl font-black text-emerald-400 tracking-tight">{onlineAgents}</span>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<XCircle className="w-12 h-12 text-red-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Offline Agents</span>
|
||||
<span className="text-2xl font-black text-red-400 tracking-tight">{offlineAgents}</span>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Activity className="w-12 h-12 text-cyan-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Avg. Historical Uptime</span>
|
||||
<span className="text-2xl font-black text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Active Agents {agents.length > 0 ? `(${agents.length})` : ''}</CardTitle>
|
||||
<CardTitle className="text-white">Provisioned Network Agents ({agents.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error ? (
|
||||
<div className="p-6 bg-red-950/20 border border-red-500/30 rounded-xl space-y-4 text-slate-300">
|
||||
<div className="flex items-center gap-2 text-red-400 font-semibold">
|
||||
<XCircle className="w-5 h-5" />
|
||||
<span>Akses Agents Ditolak (403 Forbidden)</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed">
|
||||
API Key yang Anda gunakan saat ini (<code className="bg-red-950/40 text-red-300 px-1.5 py-0.5 rounded font-mono text-xs">sk_db_live_...</code>) adalah <strong>Site-scoped API Key</strong>. BackOne membatasi kunci ini agar hanya dapat membaca data statistik jaringan (Informatics), sehingga tidak memiliki izin untuk mengelola inventaris Agents global.
|
||||
</p>
|
||||
<div className="bg-slate-900/60 rounded-lg p-4 border border-border/40 text-xs space-y-2 leading-relaxed">
|
||||
<p className="font-semibold text-white">💡 Cara Mengatasinya:</p>
|
||||
<ol className="list-decimal pl-4 space-y-1 text-slate-400">
|
||||
<li>Buka dan login ke portal <a href="https://portal.netify.ai" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline font-medium">BackOne Portal</a>.</li>
|
||||
<li>Di menu sebelah kiri, navigasikan ke <strong>Settings</strong> > <strong>API Keys</strong>.</li>
|
||||
<li>Buat atau salin kunci tipe <strong>Standard API Key</strong>.</li>
|
||||
<li>Buka file <code className="text-slate-200">.env.local</code> di proyek Anda, lalu ganti nilai <code className="text-slate-200">BACKONE_API_KEY</code> (atau <code className="text-slate-200">NETIFY_API_KEY</code>) dengan kunci baru tersebut.</li>
|
||||
</ol>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={agents}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search agents by UUID or label..."
|
||||
<DataTable
|
||||
data={agents}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search agents..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (row.uuid || "").toLowerCase().includes(q) ||
|
||||
(row.serial || "").toLowerCase().includes(q) ||
|
||||
(row.label || "").toLowerCase().includes(q);
|
||||
return (
|
||||
(row.uuid || "").toLowerCase().includes(q) ||
|
||||
(row.serial || "").toLowerCase().includes(q) ||
|
||||
(row.label || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} title="Provision New Agent">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Agent UUID / Serial *</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="e.g. EX-AM-PL-ES"
|
||||
value={formData.agentId}
|
||||
onChange={e => setFormData({...formData, agentId: e.target.value})}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Agent Label</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Branch Office Gateway"
|
||||
value={formData.label}
|
||||
onChange={e => setFormData({...formData, label: e.target.value})}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setIsCreateOpen(false)} className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="flex items-center gap-2 bg-primary hover:bg-primary/90 text-primary-foreground px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-50">
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Create Agent
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Modal isOpen={isDeleteOpen} onClose={() => setIsDeleteOpen(false)} title="Confirm Deletion">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-slate-300">
|
||||
Are you sure you want to delete the agent <span className="font-bold text-white">{selectedAgent?.label || selectedAgent?.uuid}</span>? This action cannot be undone.
|
||||
</p>
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setIsDeleteOpen(false)} className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors">Cancel</button>
|
||||
<button onClick={handleDelete} disabled={isSubmitting} className="flex items-center gap-2 bg-destructive hover:bg-destructive/90 text-destructive-foreground px-4 py-2 rounded-md text-sm font-medium transition-colors disabled:opacity-50">
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Delete Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Agent Detail Modal */}
|
||||
{detailAgent && (
|
||||
<AgentDetailModal
|
||||
agentUuid={detailAgent.uuid || detailAgent.serial}
|
||||
agentLabel={resolveAgentLabel(detailAgent.uuid || detailAgent.serial, managedUsers) !== (detailAgent.uuid || detailAgent.serial)
|
||||
? resolveAgentLabel(detailAgent.uuid || detailAgent.serial, managedUsers)
|
||||
: (detailAgent.label || detailAgent.uuid || detailAgent.serial)}
|
||||
onClose={() => setDetailAgent(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Agent Account Modal */}
|
||||
{accountModalOpen && accountModalAgent && (
|
||||
<AgentAccountModal
|
||||
isOpen={accountModalOpen}
|
||||
onClose={() => { setAccountModalOpen(false); setAccountModalAgent(null); }}
|
||||
onSuccess={() => { loadAgents(); }}
|
||||
agentUuid={accountModalAgent.uuid}
|
||||
agentLabel={accountModalAgent.label}
|
||||
existingUser={managedUsers.find(u => u.agent_uuid === accountModalAgent.uuid && u.role === 'AGENT_VIEWER') || null}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AgentModals
|
||||
isCreateOpen={isCreateOpen}
|
||||
setIsCreateOpen={setIsCreateOpen}
|
||||
isDeleteOpen={isDeleteOpen}
|
||||
setIsDeleteOpen={setIsDeleteOpen}
|
||||
selectedAgent={selectedAgent}
|
||||
onSuccess={loadAgents}
|
||||
detailAgent={detailAgent}
|
||||
setDetailAgent={setDetailAgent}
|
||||
accountModalOpen={accountModalOpen}
|
||||
setAccountModalOpen={setAccountModalOpen}
|
||||
accountModalAgent={accountModalAgent}
|
||||
setAccountModalAgent={setAccountModalAgent}
|
||||
managedUsers={managedUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTopApps, AppStat } from "@/lib/api";
|
||||
import { AppStat } from "@/lib/api";
|
||||
import { useTopApps } from "@/lib/api-with-context";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { AppDetailModal } from "@/components/ui/AppDetailModal";
|
||||
import { Loader2, ChevronRight } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, ChevronRight, Globe } from "lucide-react";
|
||||
import { fmtBytes, getAppFavicon } from "@/lib/utils";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
export default function AppsPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 20;
|
||||
const { data, isLoading } = useTopApps(pageSize, page * pageSize);
|
||||
const { data, isLoading } = useTopApps(pageSize);
|
||||
const [selectedApp, setSelectedApp] = useState<AppStat | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -25,7 +26,7 @@ export default function AppsPage() {
|
||||
const top5 = data.slice(0, 5);
|
||||
|
||||
const columns: Column<AppStat>[] = [
|
||||
{ header: "Last Seen", accessor: (row) => row.timestamp ? new Date(row.timestamp).toLocaleString() : '-' },
|
||||
{ header: "Last Seen", accessor: (row) => row.last_seen ? new Date(row.last_seen).toLocaleString() : '-' },
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{
|
||||
header: "Application",
|
||||
@@ -35,9 +36,16 @@ export default function AppsPage() {
|
||||
className="flex items-center gap-2 group text-left"
|
||||
title="Click to view app details"
|
||||
>
|
||||
{row.favicon && (
|
||||
<img src={row.favicon} alt={row.app_label} className="w-5 h-5 rounded-sm bg-white p-0.5 flex-shrink-0" />
|
||||
)}
|
||||
{(() => {
|
||||
const fav = row.favicon || getAppFavicon(row.app_label);
|
||||
return fav ? (
|
||||
<img src={fav} alt={row.app_label} className="w-5 h-5 rounded-sm bg-white p-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded bg-blue-500/10 border border-blue-500/25 flex items-center justify-center flex-shrink-0">
|
||||
<Globe className="w-3 h-3 text-blue-400" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<span className="group-hover:text-blue-300 transition-colors">{row.app_label}</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||
</button>
|
||||
@@ -60,7 +68,7 @@ export default function AppsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Applications
|
||||
@@ -72,7 +80,6 @@ export default function AppsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Top 5 Applications by Volume</CardTitle>
|
||||
@@ -138,6 +145,3 @@ export default function AppsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useDevices, DeviceStat } from "@/lib/api";
|
||||
import { DeviceStat } from "@/lib/api";
|
||||
import { useDevices } from "@/lib/api-with-context";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
@@ -87,7 +88,7 @@ export default function DevicesPage() {
|
||||
header: "Last Seen",
|
||||
className: "w-[13%]",
|
||||
accessor: (row) => {
|
||||
const dateStr = row.last_seen ? new Date(row.last_seen) : (row.timestamp ? new Date(row.timestamp) : null);
|
||||
const dateStr = row.last_seen ? new Date(row.last_seen) : null;
|
||||
if (!dateStr) return "-";
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
@@ -102,16 +103,23 @@ export default function DevicesPage() {
|
||||
header: "IP Address",
|
||||
className: "w-[12%]",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setSelectedDevice(row)}
|
||||
className="flex items-center justify-center gap-1.5 group mx-auto text-center"
|
||||
title="Click to view device details"
|
||||
>
|
||||
<span className="font-medium text-blue-400 group-hover:text-blue-300 transition-colors font-mono text-sm">
|
||||
{row.ip_address || "-"}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||
</button>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => setSelectedDevice(row)}
|
||||
className="flex items-center justify-center gap-1 group text-center"
|
||||
title="Click to view device details"
|
||||
>
|
||||
<span className="font-semibold text-blue-400 group-hover:text-blue-300 transition-colors font-mono text-sm">
|
||||
{row.ip_address || "-"}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||
</button>
|
||||
{row.device_label && (
|
||||
<span className="text-[10px] text-slate-400 font-medium mt-0.5 truncate max-w-[120px]" title={row.device_label}>
|
||||
{row.device_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -214,6 +222,21 @@ export default function DevicesPage() {
|
||||
|
||||
return matchesSearch && matchesMan && matchesType && matchesOS;
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `devices-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "Device Label", "MAC Address", "Device Type", "OS", "Manufacturer", "Download (bytes)", "Upload (bytes)", "Last Seen"],
|
||||
rowSerializer: (row) => [
|
||||
row.ip_address || "",
|
||||
row.device_label || "",
|
||||
row.mac_address || "",
|
||||
row.device_type || "",
|
||||
row.os_label || "",
|
||||
row.manufacturer || "",
|
||||
row.download ?? 0,
|
||||
row.upload ?? 0,
|
||||
row.last_seen ? new Date(row.last_seen).toISOString() : "",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useDNS } from "@/lib/api";
|
||||
import { useDNS } from "@/lib/api-with-context";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
@@ -1,217 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { Tabs, Tab } from "@/components/ui/Tabs";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import {
|
||||
useNetbios, useDiscoveryOs, useDhcpFingerprints,
|
||||
useHttpUserAgents, useSniHostnames, useSslServerCn,
|
||||
useQuicHostnames, useBittorrentHashes, useSshVersions,
|
||||
useMdnsHostnames
|
||||
import {
|
||||
useNetbios, useDiscoveryOs, useDhcpFingerprints,
|
||||
useHttpUserAgents, useSniHostnames, useSslServerCn,
|
||||
useQuicHostnames, useBittorrentHashes, useSshVersions,
|
||||
useMdnsHostnames
|
||||
} from "@/lib/api-advanced";
|
||||
import { MetadataDetailPanel } from "@/components/dpi/MetadataDetailPanel";
|
||||
import { MetadataDetailType } from "@/lib/api-metadata-detail";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
// Clickable hint badge shown in table column headers when rows are interactive
|
||||
function ClickHint() {
|
||||
return (
|
||||
<span className="ml-2 text-[10px] font-normal text-blue-400/70 border border-blue-400/30 rounded px-1 py-0.5 align-middle">
|
||||
Click row for details
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DpiAnalyticsPage() {
|
||||
const { timeRange } = useTimeFilter();
|
||||
// Detail panel state
|
||||
const [detailType, setDetailType] = useState<MetadataDetailType | null>(null);
|
||||
const [detailValue, setDetailValue] = useState<string | null>(null);
|
||||
|
||||
const openDetail = useCallback((type: MetadataDetailType, value: string) => {
|
||||
if (!value || value === "—") return;
|
||||
setDetailType(type);
|
||||
setDetailValue(value);
|
||||
}, []);
|
||||
|
||||
const closeDetail = useCallback(() => {
|
||||
setDetailType(null);
|
||||
setDetailValue(null);
|
||||
}, []);
|
||||
|
||||
// Hostnames & Servers Tab
|
||||
const { data: sniHostnames, isLoading: loadingSni } = useSniHostnames(20);
|
||||
const { data: sslServerCn, isLoading: loadingSslCn } = useSslServerCn(20);
|
||||
const { data: quicHostnames, isLoading: loadingQuic } = useQuicHostnames(20);
|
||||
const { data: netbios, isLoading: loadingNetbios } = useNetbios(20);
|
||||
const { data: mdnsHostnames, isLoading: loadingMdns } = useMdnsHostnames(20);
|
||||
const { data: sniHostnames, isLoading: loadingSni } = useSniHostnames(50);
|
||||
const { data: sslServerCn, isLoading: loadingSslCn } = useSslServerCn(50);
|
||||
const { data: quicHostnames, isLoading: loadingQuic } = useQuicHostnames(50);
|
||||
const { data: netbios, isLoading: loadingNetbios } = useNetbios(50);
|
||||
const { data: mdnsHostnames, isLoading: loadingMdns } = useMdnsHostnames(50);
|
||||
|
||||
// Client Fingerprints Tab
|
||||
const { data: discoveryOs, isLoading: loadingOs } = useDiscoveryOs(20);
|
||||
const { data: dhcpFingerprints, isLoading: loadingDhcp } = useDhcpFingerprints(20);
|
||||
const { data: httpUserAgents, isLoading: loadingHttpUa } = useHttpUserAgents(20);
|
||||
const { data: discoveryOs, isLoading: loadingOs } = useDiscoveryOs(50);
|
||||
const { data: dhcpFingerprints, isLoading: loadingDhcp } = useDhcpFingerprints(50);
|
||||
const { data: httpUserAgents, isLoading: loadingHttpUa } = useHttpUserAgents(50);
|
||||
|
||||
// Specific Protocols Tab
|
||||
const { data: sshVersions, isLoading: loadingSsh } = useSshVersions(20);
|
||||
const { data: bittorrentHashes, isLoading: loadingBittorrent } = useBittorrentHashes(20);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0 || !bytes) return "0 B";
|
||||
const k = 1024;
|
||||
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];
|
||||
};
|
||||
const { data: sshVersions, isLoading: loadingSsh } = useSshVersions(50);
|
||||
const { data: bittorrentHashes, isLoading: loadingBittorrent } = useBittorrentHashes(50);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">DPI Analytics</h1>
|
||||
<p className="text-slate-400 mt-2">Deep Packet Inspection metadata and fingerprinting.</p>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">BackOne Metadata</h1>
|
||||
<p className="text-slate-400 mt-2">
|
||||
Deep Packet Inspection metadata and fingerprinting.{" "}
|
||||
<span className="text-blue-400 text-sm">Click any row to view device-level detail.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
{/* ── Hostnames & Servers ──────────────────────────────────────────── */}
|
||||
<Tab label="Hostnames & Servers">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SNI Hostnames (TLS)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>SNI Hostnames (TLS)<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sniHostnames}
|
||||
<DataTable
|
||||
data={sniHostnames}
|
||||
isLoading={loadingSni}
|
||||
onRowClick={(row: any) => openDetail("sni_hostname", row.sni_hostname)}
|
||||
columns={[
|
||||
{ header: "Hostname", accessor: (row: any) => row.sni_hostname },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SSL Server Common Names</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>SSL Server Common Names<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sslServerCn}
|
||||
<DataTable
|
||||
data={sslServerCn}
|
||||
isLoading={loadingSslCn}
|
||||
onRowClick={(row: any) => openDetail("ssl_server_cn", row.ssl_server_cn)}
|
||||
columns={[
|
||||
{ header: "Common Name", accessor: (row: any) => row.ssl_server_cn },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>QUIC Hostnames</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>QUIC Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={quicHostnames}
|
||||
<DataTable
|
||||
data={quicHostnames}
|
||||
isLoading={loadingQuic}
|
||||
onRowClick={(row: any) => openDetail("quic_hostname", row.quic_hostname)}
|
||||
columns={[
|
||||
{ header: "Hostname", accessor: (row: any) => row.quic_hostname },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>NetBIOS Hostnames</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>NetBIOS Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={netbios}
|
||||
<DataTable
|
||||
data={netbios}
|
||||
isLoading={loadingNetbios}
|
||||
onRowClick={(row: any) => openDetail("netbios_hostname", row.hostname)}
|
||||
columns={[
|
||||
{ header: "Hostname", accessor: (row: any) => row.hostname },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>mDNS Hostnames</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>mDNS Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={mdnsHostnames}
|
||||
<DataTable
|
||||
data={mdnsHostnames}
|
||||
isLoading={loadingMdns}
|
||||
onRowClick={(row: any) => openDetail("mdns_hostname", row.mdns_hostname)}
|
||||
columns={[
|
||||
{ header: "Hostname", accessor: (row: any) => row.mdns_hostname },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
{/* ── Client Fingerprints ──────────────────────────────────────────── */}
|
||||
<Tab label="Client Fingerprints">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Operating Systems Discovery</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>Operating Systems Discovery<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={discoveryOs}
|
||||
<DataTable
|
||||
data={discoveryOs}
|
||||
isLoading={loadingOs}
|
||||
onRowClick={(row: any) => openDetail("os_label", row.os_label)}
|
||||
columns={[
|
||||
{ header: "Operating System", accessor: (row: any) => row.os_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Download", accessor: (row: any) => fmtBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row: any) => fmtBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DHCP Fingerprints</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>DHCP Fingerprints<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={dhcpFingerprints}
|
||||
<DataTable
|
||||
data={dhcpFingerprints}
|
||||
isLoading={loadingDhcp}
|
||||
onRowClick={(row: any) => openDetail("dhcp_fingerprint", row.fingerprint)}
|
||||
columns={[
|
||||
{ header: "Fingerprint", accessor: (row: any) => row.fingerprint },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>HTTP User Agents</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>HTTP User Agents<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={httpUserAgents}
|
||||
<DataTable
|
||||
data={httpUserAgents}
|
||||
isLoading={loadingHttpUa}
|
||||
onRowClick={(row: any) => openDetail("http_useragent", row.user_agent)}
|
||||
columns={[
|
||||
{ header: "User Agent", accessor: (row: any) => row.user_agent },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
{/* ── Specific Protocols ───────────────────────────────────────────── */}
|
||||
<Tab label="Specific Protocols">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>SSH Versions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>SSH Versions<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sshVersions}
|
||||
<DataTable
|
||||
data={sshVersions}
|
||||
isLoading={loadingSsh}
|
||||
onRowClick={(row: any) => openDetail("ssh_version", row.ssh_version)}
|
||||
columns={[
|
||||
{ header: "SSH Version", accessor: (row: any) => row.ssh_version },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>BitTorrent Hashes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardHeader><CardTitle>BitTorrent Hashes<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={bittorrentHashes}
|
||||
<DataTable
|
||||
data={bittorrentHashes}
|
||||
isLoading={loadingBittorrent}
|
||||
onRowClick={(row: any) => openDetail("bittorrent_hash", row.info_hash)}
|
||||
columns={[
|
||||
{ header: "Label", accessor: (row: any) => row.label },
|
||||
{ header: "Label", accessor: (row: any) => row.label },
|
||||
{ header: "Info Hash", accessor: (row: any) => row.info_hash },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{/* ── Row Detail Slide-Out Panel ────────────────────────────────────── */}
|
||||
<MetadataDetailPanel
|
||||
type={detailType}
|
||||
value={detailValue}
|
||||
timeRange={timeRange}
|
||||
onClose={closeDetail}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEvents } from "@/lib/api";
|
||||
import { useEvents } from "@/lib/api-with-context";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
// Helper: format UTC ISO timestamp → Waktu WIB (UTC+7)
|
||||
// Helper: format UTC ISO timestamp → Waktu WIB (UTC+7) formatted in English locale
|
||||
function formatTimestampWIB(ts: string): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString('id-ID', {
|
||||
return new Date(ts).toLocaleString('en-US', {
|
||||
timeZone: 'Asia/Jakarta',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
@@ -29,7 +30,9 @@ function formatTimestampWIB(ts: string): string {
|
||||
|
||||
function EventsContent() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { data, isLoading } = useEvents(100); // fetch 100 events
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
const [selectedMac, setSelectedMac] = useState<string | null>(null);
|
||||
const { data, isLoading } = useEvents();
|
||||
const searchParams = useSearchParams();
|
||||
const highlightId = searchParams.get("highlight");
|
||||
|
||||
@@ -53,66 +56,89 @@ function EventsContent() {
|
||||
};
|
||||
|
||||
const columns: Column<typeof data[0]>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
|
||||
{
|
||||
header: "Timestamp (WIB)",
|
||||
accessor: (row) => (
|
||||
<span className="font-mono text-xs text-slate-300 whitespace-nowrap">
|
||||
{formatTimestampWIB(row.timestamp)}
|
||||
</span>
|
||||
)
|
||||
),
|
||||
className: "w-[180px] min-w-[180px] max-w-[180px]"
|
||||
},
|
||||
{
|
||||
header: "Severity",
|
||||
accessor: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
{getSeverityIcon(row.severity)}
|
||||
<span className="capitalize">{row.severity}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px]"
|
||||
},
|
||||
{ header: "Event Type", accessor: (row) => row.event_type },
|
||||
{ header: "Event Type", accessor: (row) => row.event_type, className: "w-[130px] min-w-[130px] max-w-[130px]" },
|
||||
{
|
||||
header: "MAC Address",
|
||||
accessor: (row) => row.mac_address
|
||||
? <span className="font-mono text-xs text-emerald-400">{row.mac_address}</span>
|
||||
: <span className="text-slate-500">-</span>
|
||||
accessor: (row) => row.mac_address ? (
|
||||
<button
|
||||
onClick={() => setSelectedMac(row.mac_address)}
|
||||
className="font-mono text-xs text-emerald-400 hover:text-emerald-300 hover:underline text-center w-full focus:outline-none"
|
||||
>
|
||||
{row.mac_address}
|
||||
</button>
|
||||
) : <span className="text-slate-500">-</span>,
|
||||
className: "w-[160px] min-w-[160px] max-w-[160px]"
|
||||
},
|
||||
{
|
||||
header: "Source IP",
|
||||
accessor: (row) => <span className="text-blue-400 font-mono text-xs">{row.source_ip || "-"}</span>
|
||||
accessor: (row) => row.source_ip ? (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.source_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs text-center w-full focus:outline-none"
|
||||
>
|
||||
{row.source_ip}
|
||||
</button>
|
||||
) : <span className="text-slate-500">-</span>,
|
||||
className: "w-[140px] min-w-[140px] max-w-[140px]"
|
||||
},
|
||||
{
|
||||
header: "Message",
|
||||
accessor: (row) => {
|
||||
const msg = row.message;
|
||||
const mac = row.mac_address;
|
||||
let content;
|
||||
if (!mac || !msg.includes(mac)) {
|
||||
return <span className="text-slate-200 text-sm">{msg}</span>;
|
||||
content = <span className="text-slate-200 text-sm truncate">{msg}</span>;
|
||||
} else {
|
||||
// Highlight MAC in message with teal color
|
||||
const parts = msg.split(mac);
|
||||
content = (
|
||||
<span className="text-slate-200 text-sm truncate">
|
||||
{parts[0]}
|
||||
<span className="font-mono text-emerald-400 font-medium">{mac}</span>
|
||||
{parts[1]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Highlight MAC in message with teal color (like Netify)
|
||||
const parts = msg.split(mac);
|
||||
return (
|
||||
<span className="text-slate-200 text-sm">
|
||||
{parts[0]}
|
||||
<span className="font-mono text-emerald-400 font-medium">{mac}</span>
|
||||
{parts[1]}
|
||||
</span>
|
||||
<div title={msg} className="truncate w-full block text-center cursor-help">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
className: "text-center w-full min-w-0 max-w-full"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Network Events
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Recent Events ({data.length})</CardTitle>
|
||||
@@ -137,9 +163,18 @@ function EventsContent() {
|
||||
? "bg-yellow-500/10 border-l-2 border-yellow-500 animate-pulse font-semibold"
|
||||
: "";
|
||||
}}
|
||||
pageSize={50}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedIp && (
|
||||
<DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />
|
||||
)}
|
||||
|
||||
{selectedMac && (
|
||||
<DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -151,3 +186,4 @@ export default function EventsPage() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
export { EventsContent };
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFlows } from "@/lib/api";
|
||||
import { useFlows } from "@/lib/api-with-context";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
|
||||
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
|
||||
const label = appLabel || domain || "";
|
||||
@@ -57,7 +58,8 @@ function explainAppOrPort(appLabel: string | null, domain: string | null, port:
|
||||
|
||||
export default function FlowsPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { data, isLoading } = useFlows(100); // fetch 100 flows
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
const { data, isLoading } = useFlows();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -68,12 +70,27 @@ export default function FlowsPage() {
|
||||
const columns: Column<typeof data[0]>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{ header: "Flow ID", accessor: (row) => row.flow_id },
|
||||
{ header: "Src IP", accessor: (row) => <span className="text-blue-400">{row.src_ip}</span> },
|
||||
{
|
||||
header: "Src IP",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.src_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-left focus:outline-none"
|
||||
>
|
||||
{row.src_ip}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Dst IP",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-blue-400">{row.dst_ip}</span>
|
||||
<div className="flex flex-col items-start">
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.dst_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-left focus:outline-none"
|
||||
>
|
||||
{row.dst_ip}
|
||||
</button>
|
||||
<IpDetails ip={row.dst_ip} />
|
||||
</div>
|
||||
)
|
||||
@@ -110,14 +127,13 @@ export default function FlowsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Active Flows
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Active Flows ({data.length})</CardTitle>
|
||||
@@ -137,10 +153,27 @@ export default function FlowsPage() {
|
||||
(row.protocol || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `flows-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["Flow ID", "Src IP", "Dst IP", "Dst Port", "Protocol", "App / Domain", "Download (bytes)", "Upload (bytes)"],
|
||||
rowSerializer: (row) => [
|
||||
row.flow_id || "",
|
||||
row.src_ip || "",
|
||||
row.dst_ip || "",
|
||||
row.dst_port ?? "",
|
||||
row.protocol || "",
|
||||
row.app_label || row.domain || `Port ${row.dst_port}`,
|
||||
row.bytes_download ?? 0,
|
||||
row.bytes_upload ?? 0,
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedIp && (
|
||||
<DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useCountries } from "@/lib/api";
|
||||
import { useCountries } from "@/lib/api-with-context";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useIntelligenceStats } from "@/lib/api";
|
||||
import { useIntelligenceStats } from "@/lib/api-with-context";
|
||||
import {
|
||||
useIntelCryptoMining, useIntelTorDetection, useIntelVpnDetection,
|
||||
useIntelIpReputation, useIntelInsecureProtocols, useIntelUnencryptedPasswords,
|
||||
@@ -229,10 +229,10 @@ export default function IntelligencePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Deep Packet Inspection Intelligence
|
||||
{loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Threat Intelligence Feeds
|
||||
{loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
PieChart, Pie, Cell
|
||||
} from "recharts";
|
||||
import { Activity, Loader2 } from "lucide-react";
|
||||
import { useAppCategories, useContinents, AppCategoryStat } from "@/lib/api";
|
||||
import { AppCategoryStat } from "@/lib/api";
|
||||
import { useAppCategories, useContinents } from "@/lib/api-with-context";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
|
||||
@@ -25,21 +26,29 @@ export default function NetworkIntelligencePage() {
|
||||
if (!mounted) return null;
|
||||
|
||||
const appCols: Column<AppCategoryStat>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{ header: "Category", accessor: (row) => <span className="font-semibold text-white">{row.category_label || '-'}</span> },
|
||||
{ header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span> },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span> },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total) },
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
|
||||
{
|
||||
header: "Category",
|
||||
accessor: (row) => (
|
||||
<div title={row.category_label || '-'} className="font-semibold text-white truncate w-full block text-center cursor-help">
|
||||
{row.category_label || '-'}
|
||||
</div>
|
||||
),
|
||||
className: "w-[180px] min-w-[180px] max-w-[180px]"
|
||||
},
|
||||
{ header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total), className: "w-[120px] min-w-[120px] max-w-[120px]" },
|
||||
];
|
||||
|
||||
const isLoading = appCategories.isLoading || continents.isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Network Intelligence
|
||||
Traffic Categories
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Deep packet inspection analytics and geography.</p>
|
||||
@@ -58,7 +67,7 @@ export default function NetworkIntelligencePage() {
|
||||
<CardTitle className="text-lg text-white">App Categories List</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable data={appCategories.data || []} columns={appCols} />
|
||||
<DataTable data={appCategories.data || []} columns={appCols} pageSize={50} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
+11
-241
@@ -1,22 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Activity, Download, Upload, ShieldAlert, Zap, Server, Loader2 } from "lucide-react";
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer,
|
||||
BarChart, Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell
|
||||
} from "recharts";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols, useCountries } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols, useCountries } from "@/lib/api-with-context";
|
||||
import { GlobeMap } from "@/components/ui/GlobeMap";
|
||||
import { getOriginLocation, getDestinationLocation } from "@/lib/geoUtils";
|
||||
import Link from "next/link";
|
||||
|
||||
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
|
||||
import { KPICards } from "@/components/dashboard/KPICards";
|
||||
import { TopWidgets } from "@/components/dashboard/TopWidgets";
|
||||
|
||||
export default function SummaryPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -24,7 +14,7 @@ export default function SummaryPage() {
|
||||
const timeline = useTimeline(24);
|
||||
const rawTopApps = useTopApps(7);
|
||||
const rawTopProtocols = useTopProtocols();
|
||||
const countries = useCountries(20);
|
||||
const countries = useCountries(0); // Load all countries for complete globemap (Requirement 16)
|
||||
|
||||
const { topProtocols, topApps } = useMemo(() => {
|
||||
const rawProtos = rawTopProtocols.data || [];
|
||||
@@ -57,7 +47,7 @@ export default function SummaryPage() {
|
||||
const mapData = useMemo(() => {
|
||||
if (!countries.data) return [];
|
||||
|
||||
const origin = getOriginLocation(); // Falls back to default if no specific override
|
||||
const origin = getOriginLocation();
|
||||
|
||||
return countries.data.reduce((acc: any[], row) => {
|
||||
// Skip local-to-local if origin is Indonesia and destination is Indonesia
|
||||
@@ -75,6 +65,7 @@ export default function SummaryPage() {
|
||||
value: row.download + row.upload,
|
||||
download: row.download,
|
||||
upload: row.upload,
|
||||
countryCode: row.country_code
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
@@ -89,9 +80,6 @@ export default function SummaryPage() {
|
||||
|
||||
const isLoading = summary.isLoading || timeline.isLoading || topApps.isLoading || topProtocols.isLoading;
|
||||
|
||||
const downloadSpeedBits = summary.data?.download_speed ? summary.data.download_speed * 8 : 0;
|
||||
const uploadSpeedBits = summary.data?.upload_speed ? summary.data.upload_speed * 8 : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -105,235 +93,17 @@ export default function SummaryPage() {
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-blue-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '0ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Download className="h-16 w-16 text-blue-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-blue-400 transition-colors">Download</CardTitle>
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary.data ? fmtBytes(summary.data.bandwidth_down) : "0 B"}
|
||||
</div>
|
||||
{summary.data?.download_speed !== undefined && summary.data.download_speed > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-blue-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
Speed: {downloadSpeedBits >= 1e6
|
||||
? `${(downloadSpeedBits / 1e6).toFixed(1)} Mbps`
|
||||
: downloadSpeedBits >= 1e3
|
||||
? `${(downloadSpeedBits / 1e3).toFixed(1)} Kbps`
|
||||
: `${downloadSpeedBits} bps`}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-emerald-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '50ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Upload className="h-16 w-16 text-emerald-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-emerald-400 transition-colors">Upload</CardTitle>
|
||||
<Upload className="h-4 w-4 text-emerald-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary.data ? fmtBytes(summary.data.bandwidth_up) : "0 B"}
|
||||
</div>
|
||||
{summary.data?.upload_speed !== undefined && summary.data.upload_speed > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-emerald-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Speed: {uploadSpeedBits >= 1e6
|
||||
? `${(uploadSpeedBits / 1e6).toFixed(1)} Mbps`
|
||||
: uploadSpeedBits >= 1e3
|
||||
? `${(uploadSpeedBits / 1e3).toFixed(1)} Kbps`
|
||||
: `${uploadSpeedBits} bps`}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/devices" className="block focus:outline-none focus:ring-2 focus:ring-orange-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Server className="h-16 w-16 text-orange-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-orange-400 transition-colors">Devices</CardTitle>
|
||||
<Server className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary.data?.total_devices?.toLocaleString() || "0"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-purple-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '150ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Activity className="h-16 w-16 text-purple-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-purple-400 transition-colors">Flows</CardTitle>
|
||||
<Activity className="h-4 w-4 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary.data?.active_flows?.toLocaleString() || "0"}
|
||||
</div>
|
||||
{summary.data?.flow_speed !== undefined && summary.data.flow_speed > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-purple-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse" />
|
||||
Speed: {summary.data.flow_speed.toLocaleString()} /hr
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/threats" className="block focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-red-50/90 dark:bg-card/80 backdrop-blur-xl border-red-200 dark:border-red-500/20 hover:bg-red-100 hover:dark:bg-card/100 hover:border-red-300 hover:dark:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className="absolute inset-0 bg-red-500/10 dark:bg-red-500/5 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 p-4 opacity-20 dark:opacity-10 group-hover:opacity-30 dark:group-hover:opacity-20 transition-opacity text-red-600 dark:text-red-500">
|
||||
<ShieldAlert className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-semibold text-red-700 dark:text-red-400 group-hover:text-red-800 dark:group-hover:text-red-300 transition-colors">Threats</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-500 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-red-600 dark:text-red-500 tracking-tight">
|
||||
{summary.data?.total_threats?.toLocaleString() || "0"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/events" className="block focus:outline-none focus:ring-2 focus:ring-yellow-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '250ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-yellow-500">
|
||||
<Zap className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-yellow-400 transition-colors">Events</CardTitle>
|
||||
<Zap className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary.data?.total_events?.toLocaleString() || "0"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
<KPICards summary={summary.data} />
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||
{/* Global Connections Map (Replaces Timeline) */}
|
||||
{/* Global Connections Map */}
|
||||
<div className="col-span-4 relative flex items-center justify-center">
|
||||
<GlobeMap data={mapData} />
|
||||
</div>
|
||||
|
||||
{/* Top Apps & Protocols */}
|
||||
<div className="col-span-3 space-y-6">
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white">Top Apps (Download)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[150px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topApps.data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis dataKey="app_label" stroke="#a1a1aa" fontSize={11} tickLine={false} axisLine={false} tickMargin={5} />
|
||||
<YAxis
|
||||
stroke="#a1a1aa"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
const mb = value / 1048576;
|
||||
return `${mb.toFixed(0)}M`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(24, 24, 27, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: '#fafafa',
|
||||
}}
|
||||
itemStyle={{ color: '#fafafa', fontSize: '13px', fontWeight: 500 }}
|
||||
formatter={(value: any) => [fmtBytes(value as number), 'Download']}
|
||||
/>
|
||||
<Bar dataKey="download" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white">Top Protocols</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[150px] w-full flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={topProtocols.data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={70}
|
||||
paddingAngle={4}
|
||||
dataKey="download"
|
||||
nameKey="protocol_label"
|
||||
stroke="none"
|
||||
>
|
||||
{topProtocols.data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} className="hover:opacity-80 transition-opacity outline-none" />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(24, 24, 27, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: '#fafafa'
|
||||
}}
|
||||
itemStyle={{ color: '#fafafa', fontSize: '13px', fontWeight: 500 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{/* Custom Legend */}
|
||||
<div className="flex flex-col justify-center ml-2 space-y-3">
|
||||
{topProtocols.data.slice(0, 5).map((entry, index) => (
|
||||
<div key={`${entry.protocol_label}-${index}`} className="flex items-center text-sm text-muted-foreground whitespace-nowrap group cursor-default">
|
||||
<div className="w-3.5 h-3.5 rounded-full mr-3 shrink-0 shadow-sm" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
|
||||
<span className="truncate max-w-[100px] group-hover:text-white transition-colors">{entry.protocol_label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Top Widgets (Apps & Protocols) */}
|
||||
<TopWidgets topApps={topApps} topProtocols={topProtocols} />
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,15 @@
|
||||
"use client";
|
||||
// src/app/(dashboard)/security-audit/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
useTlsVersions,
|
||||
useTlsCiphers,
|
||||
useTlsSecurity,
|
||||
fetchSecurityDevices,
|
||||
SecurityDevice,
|
||||
} from "@/lib/api";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { Loader2, ShieldCheck, ShieldAlert, ShieldX, AlertTriangle, CheckCircle2, Info } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import {
|
||||
PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer,
|
||||
BarChart, Bar, XAxis, YAxis, CartesianGrid,
|
||||
} from "recharts";
|
||||
|
||||
const COLORS = {
|
||||
Recommended: "#3fb950",
|
||||
Secure: "#58a6ff",
|
||||
Weak: "#f0883e",
|
||||
Insecure: "#f85149",
|
||||
};
|
||||
|
||||
const RISK_CONFIG = {
|
||||
Vulnerable: { color: "text-red-400", bg: "bg-red-500/20 ring-red-500/40", icon: <ShieldX className="w-3.5 h-3.5" />, dot: "bg-red-500" },
|
||||
Moderate: { color: "text-orange-400", bg: "bg-orange-500/20 ring-orange-500/40", icon: <ShieldAlert className="w-3.5 h-3.5" />, dot: "bg-orange-500" },
|
||||
Safe: { color: "text-green-400", bg: "bg-green-500/20 ring-green-500/40", icon: <ShieldCheck className="w-3.5 h-3.5" />, dot: "bg-green-500" },
|
||||
} as const;
|
||||
import { fetchSecurityDevices, SecurityDevice } from "@/lib/api";
|
||||
import { useTlsVersions, useTlsCiphers, useTlsSecurity } from "@/lib/api-with-context";
|
||||
import RiskSummaryCards from "@/components/security-audit/RiskSummaryCards";
|
||||
import DeviceRiskTable from "@/components/security-audit/DeviceRiskTable";
|
||||
import SecurityDistribution from "@/components/security-audit/SecurityDistribution";
|
||||
import TlsVersionsChart from "@/components/security-audit/TlsVersionsChart";
|
||||
import TlsTables from "@/components/security-audit/TlsTables";
|
||||
import SslSanTable from "@/components/security-audit/SslSanTable";
|
||||
|
||||
export default function SecurityAuditPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -39,7 +19,6 @@ export default function SecurityAuditPage() {
|
||||
|
||||
const [secDevices, setSecDevices] = useState<SecurityDevice[]>([]);
|
||||
const [secDevicesLoading, setSecDevicesLoading] = useState(true);
|
||||
const [selectedDevice, setSelectedDevice] = useState<SecurityDevice | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -51,331 +30,47 @@ export default function SecurityAuditPage() {
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const isLoading = versions.isLoading || ciphers.isLoading || security.isLoading;
|
||||
|
||||
// Risk summary counts
|
||||
const vulnerableCount = secDevices.filter((d) => d.risk_level === "Vulnerable").length;
|
||||
const moderateCount = secDevices.filter((d) => d.risk_level === "Moderate").length;
|
||||
const safeCount = secDevices.filter((d) => d.risk_level === "Safe").length;
|
||||
|
||||
const versionColumns: Column<typeof versions.data[0]>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{ header: "TLS Version", accessor: (row) => row.tls_version },
|
||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span> },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span> },
|
||||
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> },
|
||||
];
|
||||
|
||||
const cipherColumns: Column<typeof ciphers.data[0]>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{ header: "Cipher Suite", accessor: (row) => <span className="text-purple-400">{row.tls_cipher}</span> },
|
||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span> },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span> },
|
||||
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> },
|
||||
];
|
||||
|
||||
const secDeviceColumns: Column<SecurityDevice>[] = [
|
||||
{ header: "#", accessor: (_, i) => i + 1 },
|
||||
{
|
||||
header: "IP Address",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setSelectedDevice(row)}
|
||||
className="font-mono text-sm text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1 group"
|
||||
>
|
||||
{row.ip_address}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Device",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-white">{row.device_label || row.ip_address}</span>
|
||||
{row.device_type && <span className="text-xs text-slate-400">{row.device_type}</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "OS / Manufacturer",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-blue-400">{row.os_label || "-"}</span>
|
||||
<span className="text-xs text-slate-500">{row.manufacturer || ""}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Encrypted %",
|
||||
accessor: (row) => {
|
||||
const pct = row.encrypted_pct ?? 0;
|
||||
const color = pct >= 80 ? "text-green-400" : pct >= 50 ? "text-orange-400" : "text-red-400";
|
||||
const bar = pct >= 80 ? "from-green-500 to-emerald-400" : pct >= 50 ? "from-orange-500 to-yellow-400" : "from-red-500 to-pink-400";
|
||||
return (
|
||||
<div className="flex flex-col gap-1 min-w-[100px]">
|
||||
<span className={`text-sm font-semibold ${color}`}>{pct.toFixed(1)}%</span>
|
||||
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden w-24">
|
||||
<div className={`h-full bg-gradient-to-r ${bar} rounded-full`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Traffic",
|
||||
accessor: (row) => (
|
||||
<div className="text-xs text-slate-400">
|
||||
<span className="text-cyan-400">{fmtBytes(row.encrypted)}</span>
|
||||
<span className="text-slate-600"> / </span>
|
||||
<span className="text-red-400">{fmtBytes(row.unencrypted)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Risk Level",
|
||||
accessor: (row) => {
|
||||
const cfg = RISK_CONFIG[row.risk_level] ?? RISK_CONFIG.Safe;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ring-1 ${cfg.bg} ${cfg.color}`}>
|
||||
{cfg.icon}
|
||||
{row.risk_level}
|
||||
{row.has_insecure && (
|
||||
<span title="Uses insecure protocol">
|
||||
<AlertTriangle className="w-3 h-3 text-yellow-400 ml-0.5" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Security Audit
|
||||
{(isLoading || secDevicesLoading) && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">TLS encryption analysis and device-level risk assessment.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Security Overview Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{security.data.map((item, index) => (
|
||||
<Card key={`${item.tls_security}-${index}`} className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{item.tls_security} Connections
|
||||
</CardTitle>
|
||||
{item.tls_security === "Recommended" && <ShieldCheck className="h-4 w-4 text-green-500" />}
|
||||
{item.tls_security === "Secure" && <ShieldCheck className="h-4 w-4 text-blue-500" />}
|
||||
{item.tls_security === "Weak" && <ShieldAlert className="h-4 w-4 text-orange-500" />}
|
||||
{item.tls_security === "Insecure" && <ShieldX className="h-4 w-4 text-red-500" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white">{fmtBytes(item.total)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">Security & Encryption Audit</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Device Risk Summary Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="bg-red-950/20 border-red-500/20 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-red-300">Vulnerable</CardTitle>
|
||||
<ShieldX className="h-4 w-4 text-red-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-red-400">{vulnerableCount}</div>
|
||||
<p className="text-xs text-red-300/60 mt-1">device{vulnerableCount !== 1 ? "s" : ""} at risk</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-orange-950/20 border-orange-500/20 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-orange-300">Moderate</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-orange-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-orange-400">{moderateCount}</div>
|
||||
<p className="text-xs text-orange-300/60 mt-1">device{moderateCount !== 1 ? "s" : ""} moderate risk</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-green-950/20 border-green-500/20 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-green-300">Safe (Secure)</CardTitle>
|
||||
<ShieldCheck className="h-4 w-4 text-green-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-400">{safeCount}</div>
|
||||
<p className="text-xs text-green-300/60 mt-1">device{safeCount !== 1 ? "s" : ""} secure</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Risk Summary Cards */}
|
||||
<RiskSummaryCards
|
||||
vulnerableCount={vulnerableCount}
|
||||
moderateCount={moderateCount}
|
||||
safeCount={safeCount}
|
||||
/>
|
||||
|
||||
{/* Device Risk Table */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
Device Risk Assessment
|
||||
{secDevicesLoading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<span className="ml-auto text-xs text-slate-400 font-normal flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
Based on encryption audit data. Click IP to inspect device.
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{secDevices.length === 0 && !secDevicesLoading ? (
|
||||
<div className="text-center py-10 text-slate-500 text-sm">
|
||||
No encryption audit data available yet. The scheduler will populate this table automatically.
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={secDevices}
|
||||
columns={secDeviceColumns}
|
||||
searchPlaceholder="Search by IP, device type, OS..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.device_label || "").toLowerCase().includes(q) ||
|
||||
(row.device_type || "").toLowerCase().includes(q) ||
|
||||
(row.os_label || "").toLowerCase().includes(q) ||
|
||||
(row.risk_level || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DeviceRiskTable
|
||||
secDevices={secDevices}
|
||||
secDevicesLoading={secDevicesLoading}
|
||||
/>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* TLS Security Pie Chart */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Security Level Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[250px] w-full flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={security.data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="total"
|
||||
nameKey="tls_security"
|
||||
>
|
||||
{security.data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[entry.tls_security as keyof typeof COLORS] || "#8884d8"} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
|
||||
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col justify-center ml-4 space-y-2">
|
||||
{security.data.map((entry, index) => (
|
||||
<div key={`${entry.tls_security}-${index}`} className="flex items-center text-xs text-muted-foreground whitespace-nowrap">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2 shrink-0"
|
||||
style={{ backgroundColor: COLORS[entry.tls_security as keyof typeof COLORS] || "#8884d8" }}
|
||||
/>
|
||||
<span>{entry.tls_security}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TLS Versions Bar Chart */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">TLS Versions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[250px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={versions.data} margin={{ top: 10, right: 10, left: 20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" vertical={false} />
|
||||
<XAxis dataKey="tls_version" stroke="#8b949e" fontSize={10} tickLine={false} axisLine={false} />
|
||||
<YAxis
|
||||
stroke="#8b949e"
|
||||
fontSize={10}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
const gb = value / 1073741824;
|
||||
const mb = value / 1048576;
|
||||
return gb >= 1 ? `${gb.toFixed(1)}GB` : `${mb.toFixed(0)}MB`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{ fill: "#21262d" }}
|
||||
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
|
||||
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
|
||||
/>
|
||||
<Bar dataKey="total" fill="#bc8cff" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SecurityDistribution securityData={security.data} />
|
||||
<TlsVersionsChart versionsData={versions.data} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* TLS Versions Table */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">TLS Versions Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={versions.data}
|
||||
columns={versionColumns}
|
||||
searchPlaceholder="Search versions..."
|
||||
searchFilter={(row, query) => (row.tls_version || "").toLowerCase().includes(query.toLowerCase())}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* TLS Versions and Ciphers Overview tables */}
|
||||
<TlsTables
|
||||
versionsData={versions.data}
|
||||
ciphersData={ciphers.data}
|
||||
/>
|
||||
|
||||
{/* TLS Ciphers Table */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Top TLS Cipher Suites</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={ciphers.data}
|
||||
columns={cipherColumns}
|
||||
searchPlaceholder="Search ciphers..."
|
||||
searchFilter={(row, query) => (row.tls_cipher || "").toLowerCase().includes(query.toLowerCase())}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Device Detail Modal */}
|
||||
{selectedDevice && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip_address}
|
||||
deviceLabel={selectedDevice.device_label}
|
||||
macAddress={selectedDevice.mac_address}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
{/* SSL/TLS Subject Alternative Names Table */}
|
||||
<SslSanTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,221 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo, Suspense } from "react";
|
||||
import { ShieldCheck, Loader2 } from "lucide-react";
|
||||
import { useThreats, ThreatStat } from "@/lib/api";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
|
||||
import { ThreatStat } from "@/lib/api";
|
||||
import { useThreats } from "@/lib/api-with-context";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { ThreatFilters } from "@/components/threats/ThreatFilters";
|
||||
import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
|
||||
// ─── ThreatsContent ───────────────────────────────────────────────────────────
|
||||
function ThreatsContent() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 50;
|
||||
const threats = useThreats(pageSize, page * pageSize);
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
const [selectedMac, setSelectedMac] = useState<string | null>(null);
|
||||
const [selectedThreat, setSelectedThreat] = useState<ThreatStat | null>(null);
|
||||
|
||||
const threats = useThreats();
|
||||
const searchParams = useSearchParams();
|
||||
const highlightId = searchParams.get("highlight");
|
||||
|
||||
// Column dropdown filters state
|
||||
const [fType, setFType] = useState("All");
|
||||
const [fSev, setFSev] = useState("All");
|
||||
const [fSrcIp, setFSrcIp] = useState("All");
|
||||
const [fDstIp, setFDstIp] = useState("All");
|
||||
const [fMac, setFMac] = useState("All");
|
||||
const [fApp, setFApp] = useState("All");
|
||||
const [fDomain, setFDomain] = useState("All");
|
||||
|
||||
// Date range filter state
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const all = threats.data || [];
|
||||
|
||||
// Apply all filters
|
||||
const filtered = useMemo(() => {
|
||||
return all.filter((row) => {
|
||||
if (fType !== "All" && (row.threat_type || "") !== fType) return false;
|
||||
if (fSev !== "All" && (row.severity || "") !== fSev) return false;
|
||||
if (fSrcIp !== "All" && (row.ip_address || "") !== fSrcIp) return false;
|
||||
if (fDstIp !== "All" && (row.dst_ip || "") !== fDstIp) return false;
|
||||
if (fMac !== "All" && (row.mac_address || "") !== fMac) return false;
|
||||
if (fApp !== "All" && (row.app_label || "") !== fApp) return false;
|
||||
if (fDomain !== "All" && (row.domain || "") !== fDomain) return false;
|
||||
if (dateFrom || dateTo) {
|
||||
const d = row.detected_at
|
||||
? new Date(row.detected_at).toISOString().slice(0, 10)
|
||||
: "";
|
||||
if (dateFrom && d < dateFrom) return false;
|
||||
if (dateTo && d > dateTo) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]);
|
||||
|
||||
// Automatically select a threat if highlightId is passed
|
||||
useEffect(() => {
|
||||
if (highlightId && all.length > 0) {
|
||||
const match = all.find(r => r.id.toString() === highlightId);
|
||||
if (match) {
|
||||
setSelectedThreat(match);
|
||||
}
|
||||
}
|
||||
}, [highlightId, all]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const getSeverityBadge = (sev?: string | null) => {
|
||||
if (!sev) return <Badge variant="green">Unknown</Badge>;
|
||||
const s = sev.toLowerCase();
|
||||
if (s.includes('high') || s.includes('critical')) return <Badge variant="red">{sev}</Badge>;
|
||||
if (s.includes('med')) return <Badge variant="orange">{sev}</Badge>;
|
||||
if (s.includes('low')) return <Badge variant="yellow">{sev}</Badge>;
|
||||
if (s.includes("high") || s.includes("critical")) return <Badge variant="red">{sev}</Badge>;
|
||||
if (s.includes("med")) return <Badge variant="orange">{sev}</Badge>;
|
||||
if (s.includes("low")) return <Badge variant="yellow">{sev}</Badge>;
|
||||
return <Badge variant="blue">{sev}</Badge>;
|
||||
};
|
||||
|
||||
const columns: Column<ThreatStat>[] = [
|
||||
{ header: "Timestamp", accessor: (row) => row.timestamp ? new Date(row.timestamp).toLocaleString() : '-' },
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{ header: "Type", accessor: (row) => row.threat_type || 'Unknown' },
|
||||
{ header: "Severity", accessor: (row) => getSeverityBadge(row.severity) },
|
||||
{ header: "Source IP", accessor: (row) => <span className="font-medium text-blue-400">{row.ip_address || '-'}</span> },
|
||||
{
|
||||
header: "Timestamp",
|
||||
className: "w-[155px] min-w-[155px] max-w-[155px] text-center",
|
||||
accessor: (row) =>
|
||||
row.detected_at ? new Date(row.detected_at).toLocaleString("id-ID", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
||||
}) : "-",
|
||||
},
|
||||
{ header: "#", className: "w-[40px] min-w-[40px] max-w-[40px] text-center", accessor: (_row, i) => i + 1 },
|
||||
{
|
||||
header: "Type",
|
||||
className: "w-[170px] min-w-[170px] max-w-[170px]",
|
||||
accessor: (row) => (
|
||||
<div
|
||||
title={row.description || row.threat_type || "Unknown"}
|
||||
className="truncate w-full block cursor-help text-xs leading-tight"
|
||||
>
|
||||
{row.threat_type || "Unknown"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Severity",
|
||||
className: "w-[90px] min-w-[90px] max-w-[90px] text-center",
|
||||
accessor: (row) => getSeverityBadge(row.severity),
|
||||
},
|
||||
{
|
||||
header: "Source IP",
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
|
||||
accessor: (row) =>
|
||||
row.ip_address ? (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.ip_address)}
|
||||
className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center"
|
||||
>
|
||||
{row.ip_address}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="Source IP tidak tersedia dari data event keamanan (hanya MAC Address yang ter-capture)">–</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Dest IP",
|
||||
accessor: (row) => row.dst_ip ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{row.dst_ip}</span>
|
||||
<IpDetails ip={row.dst_ip} />
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
|
||||
accessor: (row) =>
|
||||
row.dst_ip ? (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.dst_ip)}
|
||||
className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center"
|
||||
>
|
||||
{row.dst_ip}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="Dest IP tidak tersedia dari data event ini">–</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "MAC Addr",
|
||||
className: "w-[140px] min-w-[140px] max-w-[140px] text-center",
|
||||
accessor: (row) =>
|
||||
row.mac_address ? (
|
||||
<button
|
||||
onClick={() => setSelectedMac(row.mac_address)}
|
||||
className="text-xs bg-secondary/50 px-1.5 py-0.5 rounded text-muted-foreground hover:text-white hover:bg-secondary transition-colors font-mono focus:outline-none w-full text-center truncate"
|
||||
title={row.mac_address}
|
||||
>
|
||||
{row.mac_address}
|
||||
</button>
|
||||
) : "–",
|
||||
},
|
||||
{
|
||||
header: "App",
|
||||
className: "w-[110px] min-w-[110px] max-w-[110px] text-center",
|
||||
accessor: (row) => {
|
||||
const formatted = formatAppLabel(row.app_label);
|
||||
return formatted ? (
|
||||
<div title={formatted} className="truncate w-full block text-center cursor-help text-xs">
|
||||
{formatted}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="App tidak tersedia dari event ini">–</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Domain",
|
||||
className: "w-[160px] min-w-[160px] max-w-[160px] text-center",
|
||||
accessor: (row) => row.domain ? (
|
||||
<div title={row.domain} className="truncate w-full text-center text-xs cursor-help">
|
||||
{row.domain}
|
||||
</div>
|
||||
) : '-'
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="Domain tidak tersedia dari event ini">–</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
className: "w-[130px] min-w-[130px] max-w-[130px] text-center",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setSelectedThreat(row)}
|
||||
className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer whitespace-nowrap focus:outline-none"
|
||||
>
|
||||
View Mitigation
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ header: "MAC Address", accessor: (row) => row.mac_address ? <code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground">{row.mac_address}</code> : '-' },
|
||||
{ header: "App", accessor: (row) => row.app_label || '-' },
|
||||
{ header: "Domain", accessor: (row) => row.domain || '-' },
|
||||
{ header: "Time", accessor: (row) => <span className="text-muted-foreground text-xs">{row.detected_at ? new Date(row.detected_at).toLocaleString() : '-'}</span> },
|
||||
];
|
||||
|
||||
|
||||
const activeFilterCount =
|
||||
[fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain].filter((v) => v !== "All").length +
|
||||
(dateFrom || dateTo ? 1 : 0);
|
||||
|
||||
const resetFilters = () => {
|
||||
setFType("All");
|
||||
setFSev("All");
|
||||
setFSrcIp("All");
|
||||
setFDstIp("All");
|
||||
setFMac("All");
|
||||
setFApp("All");
|
||||
setFDomain("All");
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Threat Intelligence
|
||||
@@ -70,41 +228,105 @@ function ThreatsContent() {
|
||||
<span>Active Monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
{/* Filters above the table */}
|
||||
<ThreatFilters
|
||||
all={all}
|
||||
fType={fType}
|
||||
setFType={setFType}
|
||||
fSev={fSev}
|
||||
setFSev={setFSev}
|
||||
fSrcIp={fSrcIp}
|
||||
setFSrcIp={setFSrcIp}
|
||||
fDstIp={fDstIp}
|
||||
setFDstIp={setFDstIp}
|
||||
fMac={fMac}
|
||||
setFMac={setFMac}
|
||||
fApp={fApp}
|
||||
setFApp={setFApp}
|
||||
fDomain={fDomain}
|
||||
setFDomain={setFDomain}
|
||||
dateFrom={dateFrom}
|
||||
setDateFrom={setDateFrom}
|
||||
dateTo={dateTo}
|
||||
setDateTo={setDateTo}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
{/* Threats Table - Full Width */}
|
||||
<Card
|
||||
className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up relative z-10 overflow-visible"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-lg text-white">Detected Threats {threats.data ? `(${threats.data.length})` : ''}</CardTitle>
|
||||
<CardTitle className="text-lg text-white">
|
||||
Detected Threats
|
||||
<span className="ml-2 text-muted-foreground font-normal">
|
||||
({filtered.length}
|
||||
{filtered.length !== all.length ? ` of ${all.length}` : ""})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={threats.data || []}
|
||||
columns={columns}
|
||||
<DataTable
|
||||
data={filtered}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search threats by IP, domain, type..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.domain || "").toLowerCase().includes(q) ||
|
||||
(row.threat_type || "").toLowerCase().includes(q) ||
|
||||
(row.app_label || "").toLowerCase().includes(q);
|
||||
return (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.domain || "").toLowerCase().includes(q) ||
|
||||
(row.threat_type || "").toLowerCase().includes(q) ||
|
||||
(row.app_label || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
getRowClassName={(row) => {
|
||||
return highlightId && row.id.toString() === highlightId
|
||||
getRowClassName={(row) =>
|
||||
highlightId && row.id.toString() === highlightId
|
||||
? "bg-red-500/15 border-l-2 border-red-500 animate-pulse font-semibold"
|
||||
: "";
|
||||
: ""
|
||||
}
|
||||
csvExport={{
|
||||
filename: `threats-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
headers: ["Timestamp", "Type", "Severity", "Source IP", "Dest IP", "MAC Address", "App", "Domain"],
|
||||
rowSerializer: (row) => [
|
||||
row.detected_at ? new Date(row.detected_at).toISOString() : "",
|
||||
row.threat_type || "",
|
||||
row.severity || "",
|
||||
row.ip_address || "",
|
||||
row.dst_ip || "",
|
||||
row.mac_address || "",
|
||||
row.app_label || "",
|
||||
row.domain || "",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedIp && <DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />}
|
||||
{selectedMac && <DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />}
|
||||
|
||||
{/* Mitigation recommendations drawer panel */}
|
||||
<ThreatRecommendationsDrawer
|
||||
threat={selectedThreat}
|
||||
onClose={() => setSelectedThreat(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ThreatsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center p-12"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ThreatsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
+5
-5
@@ -1,12 +1,12 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import "./globals.css";
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
// Bypass google font remote fetch during offline compilation
|
||||
const inter = {
|
||||
variable: "font-sans",
|
||||
};
|
||||
|
||||
const backoneFont = localFont({
|
||||
src: "../../public/fonts/BackOneLogo-Regular.ttf",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, User, Key, Tag, Cpu, Upload, Trash2, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
@@ -80,7 +80,7 @@ export default function AgentAccountModal({
|
||||
fd.append("agent_uuid", agentUuid);
|
||||
if (picFile) fd.append("profile_picture", picFile);
|
||||
await updateAdminAgentUser(fd);
|
||||
setSuccess("Akun berhasil diperbarui!");
|
||||
setSuccess("Account successfully updated!");
|
||||
} else {
|
||||
await createAdminAgentUser({
|
||||
username: username.trim(),
|
||||
@@ -88,7 +88,7 @@ export default function AgentAccountModal({
|
||||
account_name: accountName.trim() || undefined,
|
||||
agent_uuid: agentUuid,
|
||||
});
|
||||
setSuccess("Akun Network Agent berhasil dibuat!");
|
||||
setSuccess("Network Agent account successfully created!");
|
||||
}
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
@@ -152,8 +152,8 @@ export default function AgentAccountModal({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">Foto Profil</p>
|
||||
<p className="text-xs text-slate-500">Klik untuk upload gambar</p>
|
||||
<p className="text-sm font-medium text-slate-300">Profile Picture</p>
|
||||
<p className="text-xs text-slate-500">Click to upload image</p>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePicChange} />
|
||||
</div>
|
||||
@@ -171,14 +171,14 @@ export default function AgentAccountModal({
|
||||
placeholder="e.g., Gateway 007"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">Nama ini akan tampil sebagai label di tab Agents</p>
|
||||
<p className="text-xs text-slate-500 mt-1">This name will appear as a label in the Agents tab</p>
|
||||
</div>
|
||||
|
||||
{/* Agent UUID (readonly info) */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Cpu className="w-3.5 h-3.5 inline mr-1" />
|
||||
UUID Agent
|
||||
Agent UUID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -192,13 +192,13 @@ export default function AgentAccountModal({
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Username Login
|
||||
Login Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Username untuk login"
|
||||
placeholder="Username for login"
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
@@ -208,13 +208,13 @@ export default function AgentAccountModal({
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
Password {isEdit && <span className="text-slate-500">(kosongkan jika tidak ingin mengubah)</span>}
|
||||
Password {isEdit && <span className="text-slate-500">(leave blank to keep unchanged)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "••••••••" : "Password baru"}
|
||||
placeholder={isEdit ? "••••••••" : "New password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { createAgent, deleteAgent, Agent } from "@/lib/actions/agents";
|
||||
import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
|
||||
import AgentAccountModal from "@/components/admin/AgentAccountModal";
|
||||
import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
|
||||
|
||||
interface Props {
|
||||
isCreateOpen: boolean;
|
||||
setIsCreateOpen: (open: boolean) => void;
|
||||
isDeleteOpen: boolean;
|
||||
setIsDeleteOpen: (open: boolean) => void;
|
||||
selectedAgent: Agent | null;
|
||||
onSuccess: () => void;
|
||||
// Detail modal state
|
||||
detailAgent: Agent | null;
|
||||
setDetailAgent: (agent: Agent | null) => void;
|
||||
// Account modal state
|
||||
accountModalOpen: boolean;
|
||||
setAccountModalOpen: (open: boolean) => void;
|
||||
accountModalAgent: { uuid: string; label: string } | null;
|
||||
setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void;
|
||||
managedUsers: ManagedUser[];
|
||||
}
|
||||
|
||||
export function AgentModals({
|
||||
isCreateOpen,
|
||||
setIsCreateOpen,
|
||||
isDeleteOpen,
|
||||
setIsDeleteOpen,
|
||||
selectedAgent,
|
||||
onSuccess,
|
||||
detailAgent,
|
||||
setDetailAgent,
|
||||
accountModalOpen,
|
||||
setAccountModalOpen,
|
||||
accountModalAgent,
|
||||
setAccountModalAgent,
|
||||
managedUsers
|
||||
}: Props) {
|
||||
const [formData, setFormData] = useState({ agentId: "", label: "" });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const res = await createAgent(formData.agentId, formData.label);
|
||||
setIsSubmitting(false);
|
||||
if (res.success) {
|
||||
setIsCreateOpen(false);
|
||||
setFormData({ agentId: "", label: "" });
|
||||
onSuccess();
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedAgent) return;
|
||||
setIsSubmitting(true);
|
||||
const res = await deleteAgent(selectedAgent.uuid || selectedAgent.serial);
|
||||
setIsSubmitting(false);
|
||||
if (res.success) {
|
||||
setIsDeleteOpen(false);
|
||||
onSuccess();
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} title="Provision New Agent">
|
||||
<form onSubmit={handleCreate} className="space-y-4 text-xs">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Agent UUID / Serial *</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="e.g. EX-AM-PL-ES"
|
||||
value={formData.agentId}
|
||||
onChange={e => setFormData({...formData, agentId: e.target.value})}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Agent Label</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Branch Office Gateway"
|
||||
value={formData.label}
|
||||
onChange={e => setFormData({...formData, label: e.target.value})}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setIsCreateOpen(false)} className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all disabled:opacity-50">
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Create Agent
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Modal isOpen={isDeleteOpen} onClose={() => setIsDeleteOpen(false)} title="Confirm Deletion">
|
||||
<div className="space-y-4 text-xs">
|
||||
<p className="text-sm text-slate-300">
|
||||
Are you sure you want to delete the agent <span className="font-bold text-white">{selectedAgent?.label || selectedAgent?.uuid}</span>? This action cannot be undone.
|
||||
</p>
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setIsDeleteOpen(false)} className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors">Cancel</button>
|
||||
<button onClick={handleDelete} disabled={isSubmitting} className="flex items-center gap-2 bg-destructive hover:bg-destructive/90 text-destructive-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all disabled:opacity-50">
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Delete Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Agent Detail Modal */}
|
||||
{detailAgent && (
|
||||
<AgentDetailModal
|
||||
agentUuid={detailAgent.uuid || detailAgent.serial}
|
||||
agentLabel={resolveAgentLabel(detailAgent.uuid || detailAgent.serial, managedUsers) !== (detailAgent.uuid || detailAgent.serial)
|
||||
? resolveAgentLabel(detailAgent.uuid || detailAgent.serial, managedUsers)
|
||||
: (detailAgent.label || detailAgent.uuid || detailAgent.serial)}
|
||||
onClose={() => setDetailAgent(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Agent Account Modal */}
|
||||
{accountModalOpen && accountModalAgent && (
|
||||
<AgentAccountModal
|
||||
isOpen={accountModalOpen}
|
||||
onClose={() => { setAccountModalOpen(false); setAccountModalAgent(null); }}
|
||||
onSuccess={onSuccess}
|
||||
agentUuid={accountModalAgent.uuid}
|
||||
agentLabel={accountModalAgent.label}
|
||||
existingUser={managedUsers.find(u => u.agent_uuid === accountModalAgent.uuid && u.role === 'AGENT_VIEWER') || null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Download, Upload, Server, Activity, ShieldAlert, Zap } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface KPICardsProps {
|
||||
summary: {
|
||||
bandwidth_down: number;
|
||||
bandwidth_up: number;
|
||||
total_devices: number;
|
||||
active_flows: number;
|
||||
total_threats: number;
|
||||
total_events: number;
|
||||
download_speed?: number;
|
||||
upload_speed?: number;
|
||||
flow_speed?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function KPICards({ summary }: KPICardsProps) {
|
||||
const downloadSpeedBits = summary?.download_speed ? summary.download_speed * 8 : 0;
|
||||
const uploadSpeedBits = summary?.upload_speed ? summary.upload_speed * 8 : 0;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-blue-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '0ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Download className="h-16 w-16 text-blue-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-blue-400 transition-colors">Download</CardTitle>
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary ? fmtBytes(summary.bandwidth_down) : "0 B"}
|
||||
</div>
|
||||
{summary?.download_speed !== undefined && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-blue-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
Speed: {downloadSpeedBits >= 1e6
|
||||
? `${(downloadSpeedBits / 1e6).toFixed(1)} Mbps`
|
||||
: downloadSpeedBits >= 1e3
|
||||
? `${(downloadSpeedBits / 1e3).toFixed(1)} Kbps`
|
||||
: `${downloadSpeedBits.toFixed(1)} bps`}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-emerald-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '50ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Upload className="h-16 w-16 text-emerald-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-emerald-400 transition-colors">Upload</CardTitle>
|
||||
<Upload className="h-4 w-4 text-emerald-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary ? fmtBytes(summary.bandwidth_up) : "0 B"}
|
||||
</div>
|
||||
{summary?.upload_speed !== undefined && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-emerald-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Speed: {uploadSpeedBits >= 1e6
|
||||
? `${(uploadSpeedBits / 1e6).toFixed(1)} Mbps`
|
||||
: uploadSpeedBits >= 1e3
|
||||
? `${(uploadSpeedBits / 1e3).toFixed(1)} Kbps`
|
||||
: `${uploadSpeedBits.toFixed(1)} bps`}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/devices" className="block focus:outline-none focus:ring-2 focus:ring-orange-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Server className="h-16 w-16 text-orange-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-orange-400 transition-colors">Devices</CardTitle>
|
||||
<Server className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary?.total_devices?.toLocaleString() || "0"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-purple-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '150ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Activity className="h-16 w-16 text-purple-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-purple-400 transition-colors">Flows</CardTitle>
|
||||
<Activity className="h-4 w-4 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary?.active_flows?.toLocaleString() || "0"}
|
||||
</div>
|
||||
{summary?.flow_speed !== undefined && summary.flow_speed > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-purple-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse" />
|
||||
Speed: {summary.flow_speed.toLocaleString()} /hr
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/threats" className="block focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-red-50/90 dark:bg-card/80 backdrop-blur-xl border-red-200 dark:border-red-500/20 hover:bg-red-100 hover:dark:bg-card/100 hover:border-red-300 hover:dark:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className="absolute inset-0 bg-red-500/10 dark:bg-red-500/5 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 p-4 opacity-20 dark:opacity-10 group-hover:opacity-30 dark:group-hover:opacity-20 transition-opacity text-red-600 dark:text-red-500">
|
||||
<ShieldAlert className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-semibold text-red-700 dark:text-red-400 group-hover:text-red-800 dark:group-hover:text-red-300 transition-colors">Threats</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-500 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-red-600 dark:text-red-500 tracking-tight">
|
||||
{summary?.total_threats?.toLocaleString() || "0"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/events" className="block focus:outline-none focus:ring-2 focus:ring-yellow-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '250ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-yellow-500">
|
||||
<Zap className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-yellow-400 transition-colors">Events</CardTitle>
|
||||
<Zap className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary?.total_events?.toLocaleString() || "0"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, CartesianGrid, XAxis, YAxis,
|
||||
Tooltip as RechartsTooltip, PieChart, Pie, Cell
|
||||
} from "recharts";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface TopWidgetsProps {
|
||||
topApps: {
|
||||
data: { app_label: string; download: number }[];
|
||||
};
|
||||
topProtocols: {
|
||||
data: { protocol_label: string; download: number }[];
|
||||
};
|
||||
}
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#3b82f6'];
|
||||
|
||||
export function TopWidgets({ topApps, topProtocols }: TopWidgetsProps) {
|
||||
return (
|
||||
<div className="col-span-3 space-y-6">
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white">Top Apps (Download)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(!topApps.data || topApps.data.length === 0) ? (
|
||||
<div className="h-[150px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No Data
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[150px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topApps.data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis dataKey="app_label" stroke="#a1a1aa" fontSize={11} tickLine={false} axisLine={false} tickMargin={5} />
|
||||
<YAxis
|
||||
stroke="#a1a1aa"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
const mb = value / 1048576;
|
||||
return `${mb.toFixed(0)}M`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(24, 24, 27, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: '#fafafa',
|
||||
}}
|
||||
itemStyle={{ color: '#fafafa', fontSize: '13px', fontWeight: 500 }}
|
||||
formatter={(value: any) => [fmtBytes(value as number), 'Download']}
|
||||
/>
|
||||
<Bar dataKey="download" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white">Top Protocols</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(!topProtocols.data || topProtocols.data.length === 0) ? (
|
||||
<div className="h-[150px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No Data
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[150px] w-full flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={topProtocols.data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={70}
|
||||
paddingAngle={4}
|
||||
dataKey="download"
|
||||
nameKey="protocol_label"
|
||||
stroke="none"
|
||||
>
|
||||
{topProtocols.data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} className="hover:opacity-80 transition-opacity outline-none" />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(24, 24, 27, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: '#fafafa'
|
||||
}}
|
||||
itemStyle={{ color: '#fafafa', fontSize: '13px', fontWeight: 500 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{/* Custom Legend */}
|
||||
<div className="flex flex-col justify-center ml-2 space-y-3">
|
||||
{topProtocols.data.slice(0, 5).map((entry, index) => (
|
||||
<div key={`${entry.protocol_label}-${index}`} className="flex items-center text-sm text-muted-foreground whitespace-nowrap group cursor-default">
|
||||
<div className="w-3.5 h-3.5 rounded-full mr-3 shrink-0 shadow-sm" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
|
||||
<span className="truncate max-w-[100px] group-hover:text-white transition-colors">{entry.protocol_label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
// src/components/dpi/MetadataDetailPanel.tsx
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Row-level detail modal for BackOne Metadata page.
|
||||
// Matches animation style of DeviceDetailModal and AgentDetailModal.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Loader2, Database, AlertCircle, Globe, Monitor, Cpu, Fingerprint, Hash, Terminal, Radio } from "lucide-react";
|
||||
import { useMetadataDetail, MetadataDetailType, MetadataDetailRow } from "@/lib/api-metadata-detail";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
// ─── Type config ───────────────────────────────────────────────────────────────
|
||||
interface TypeConfig {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<MetadataDetailType, TypeConfig> = {
|
||||
sni_hostname: { title: "SNI Hostname", subtitle: "Devices that accessed this TLS hostname", icon: Globe, color: "blue" },
|
||||
ssl_server_cn: { title: "SSL Common Name", subtitle: "Devices that connected to this certificate", icon: Globe, color: "blue" },
|
||||
quic_hostname: { title: "QUIC Hostname", subtitle: "Devices using this QUIC destination", icon: Globe, color: "purple" },
|
||||
netbios_hostname:{ title: "NetBIOS Hostname", subtitle: "Device records with this NetBIOS name", icon: Monitor, color: "cyan" },
|
||||
os_label: { title: "Operating System", subtitle: "Devices running this operating system", icon: Cpu, color: "emerald" },
|
||||
dhcp_fingerprint:{ title: "DHCP Fingerprint", subtitle: "Agents with this device fingerprint class", icon: Fingerprint, color: "amber" },
|
||||
http_useragent: { title: "HTTP User Agent", subtitle: "Agent records for this User Agent string", icon: Globe, color: "orange" },
|
||||
bittorrent_hash: { title: "BitTorrent Hash", subtitle: "Agent records for this torrent info hash", icon: Hash, color: "red" },
|
||||
ssh_version: { title: "SSH Version", subtitle: "SSH client and server connections", icon: Terminal, color: "violet" },
|
||||
mdns_hostname: { title: "mDNS Hostname", subtitle: "Agent records for this mDNS announcement", icon: Radio, color: "teal" },
|
||||
};
|
||||
|
||||
const COLOR_MAP: Record<string, { icon: string; ring: string; glow: string }> = {
|
||||
blue: { icon: "text-blue-400", ring: "ring-blue-500/30", glow: "bg-blue-500" },
|
||||
purple: { icon: "text-purple-400", ring: "ring-purple-500/30", glow: "bg-purple-500" },
|
||||
cyan: { icon: "text-cyan-400", ring: "ring-cyan-500/30", glow: "bg-cyan-500" },
|
||||
emerald:{ icon: "text-emerald-400", ring: "ring-emerald-500/30", glow: "bg-emerald-500" },
|
||||
amber: { icon: "text-amber-400", ring: "ring-amber-500/30", glow: "bg-amber-500" },
|
||||
orange: { icon: "text-orange-400", ring: "ring-orange-500/30", glow: "bg-orange-500" },
|
||||
red: { icon: "text-red-400", ring: "ring-red-500/30", glow: "bg-red-500" },
|
||||
violet: { icon: "text-violet-400", ring: "ring-violet-500/30", glow: "bg-violet-500" },
|
||||
teal: { icon: "text-teal-400", ring: "ring-teal-500/30", glow: "bg-teal-500" },
|
||||
};
|
||||
|
||||
// ─── Cell with overflow ellipsis + title tooltip ────────────────────────────────
|
||||
// ─── Cell with overflow ellipsis + title tooltip ────────────────────────────────
|
||||
function Cell({ value, className = "", center = true }: { value: string | number; className?: string; center?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
return (
|
||||
<div
|
||||
className={`truncate whitespace-nowrap overflow-hidden text-ellipsis ${center ? "text-center" : ""} ${className}`}
|
||||
title={str}
|
||||
>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Column definitions with even percentage widths ────────────────────────────
|
||||
type ColDef = { header: string; width: string; align?: "center" };
|
||||
|
||||
const COLS: Record<string, ColDef[]> = {
|
||||
domain: [
|
||||
{ header: "Source IP", width: "w-[12.5%]" },
|
||||
{ header: "MAC Address", width: "w-[12.5%]" },
|
||||
{ header: "Device Name", width: "w-[12.5%]" },
|
||||
{ header: "Manufacturer", width: "w-[12.5%]" },
|
||||
{ header: "OS", width: "w-[12.5%]" },
|
||||
{ header: "Download", width: "w-[12.5%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[12.5%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[12.5%]", align: "center" },
|
||||
],
|
||||
device: [
|
||||
{ header: "IP Address", width: "w-[16.6%]" },
|
||||
{ header: "MAC Address", width: "w-[16.6%]" },
|
||||
{ header: "Device Name", width: "w-[16.6%]" },
|
||||
{ header: "Manufacturer", width: "w-[16.6%]" },
|
||||
{ header: "Download", width: "w-[16.6%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[16.6%]", align: "center" },
|
||||
],
|
||||
torrent: [
|
||||
{ header: "Agent", width: "w-[20%]" },
|
||||
{ header: "Torrent Label", width: "w-[20%]" },
|
||||
{ header: "Download", width: "w-[20%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[20%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[20%]", align: "center" },
|
||||
],
|
||||
ssh: [
|
||||
{ header: "Role", width: "w-[20%]", align: "center" },
|
||||
{ header: "Agent", width: "w-[20%]" },
|
||||
{ header: "Download", width: "w-[20%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[20%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[20%]", align: "center" },
|
||||
],
|
||||
default: [
|
||||
{ header: "Agent", width: "w-[25%]" },
|
||||
{ header: "Download", width: "w-[25%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[25%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[25%]", align: "center" },
|
||||
],
|
||||
};
|
||||
|
||||
function getColGroup(type: MetadataDetailType): string {
|
||||
if (["sni_hostname", "ssl_server_cn", "quic_hostname"].includes(type)) return "domain";
|
||||
if (["netbios_hostname", "os_label"].includes(type)) return "device";
|
||||
if (type === "bittorrent_hash") return "torrent";
|
||||
if (type === "ssh_version") return "ssh";
|
||||
return "default";
|
||||
}
|
||||
|
||||
// ─── Table Head ────────────────────────────────────────────────────────────────
|
||||
function TableHead({ cols }: { cols: ColDef[] }) {
|
||||
const base = "px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center";
|
||||
return (
|
||||
<tr>
|
||||
{cols.map((c) => (
|
||||
<th key={c.header} className={`${base} ${c.width}`}>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Table Row ─────────────────────────────────────────────────────────────────
|
||||
function TableRow({ row, group }: { row: MetadataDetailRow; group: string }) {
|
||||
const tr = "border-b border-white/[0.04] hover:bg-white/[0.04] transition-colors duration-150";
|
||||
const tc = "px-3 py-2.5 text-sm text-center align-middle";
|
||||
|
||||
if (group === "domain") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.src_ip || "—"} className="text-blue-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.mac_address || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.device_label || row.src_ip || "—"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={row.manufacturer || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.os_label || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "device") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.ip_address || "—"} className="text-blue-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.mac_address || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.device_label || "—"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={row.manufacturer || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "torrent") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.label || "Unknown"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "ssh") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}>
|
||||
<div className="flex justify-center">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
row.role === "Client" ? "bg-blue-500/20 text-blue-300" : "bg-purple-500/20 text-purple-300"
|
||||
}`}>{row.role || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Props ─────────────────────────────────────────────────────────────────────
|
||||
interface Props {
|
||||
type: MetadataDetailType | null;
|
||||
value: string | null;
|
||||
timeRange: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// ─── Main Modal ────────────────────────────────────────────────────────────────
|
||||
export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props) {
|
||||
const isOpen = !!(type && value);
|
||||
const { data, isLoading, error } = useMetadataDetail(type, value, timeRange);
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => { setIsMounted(true); }, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setVisible(false);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "unset";
|
||||
onClose();
|
||||
}, 480);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setVisible(true);
|
||||
setIsClosing(false);
|
||||
setPage(1);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") handleClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => () => { document.body.style.overflow = "unset"; }, []);
|
||||
|
||||
if (!isMounted || !visible || !type || !value) return null;
|
||||
|
||||
const cfg = TYPE_CONFIG[type];
|
||||
const colors = COLOR_MAP[cfg.color];
|
||||
const Icon = cfg.icon;
|
||||
const group = getColGroup(type);
|
||||
const cols = COLS[group];
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(data.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const pageStart = (safePage - 1) * PAGE_SIZE;
|
||||
const pagedData = data.slice(pageStart, pageStart + PAGE_SIZE);
|
||||
|
||||
const totalDownload = data.reduce((s, r) => s + (r.download || 0), 0);
|
||||
const totalUpload = data.reduce((s, r) => s + (r.upload || 0), 0);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
{/* Backdrop */}
|
||||
<div className={`absolute inset-0 bg-black/60 backdrop-blur-md ${isClosing ? "animate-overlay-out" : "animate-overlay-in"}`} />
|
||||
|
||||
{/* Dialog */}
|
||||
<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 overflow-hidden ${isClosing ? "animate-modal-out" : "animate-modal-in"}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top accent line */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center justify-between px-6 py-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="relative flex-shrink-0">
|
||||
<div className={`absolute inset-0 ${colors.glow} blur-xl opacity-20 rounded-full`} />
|
||||
<div className={`p-2.5 bg-white/[0.06] rounded-xl ring-1 ${colors.ring} relative`}>
|
||||
<Icon className={`w-5 h-5 ${colors.icon}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className={`text-xs font-semibold uppercase tracking-widest mb-0.5 ${colors.icon}`}>{cfg.title}</p>
|
||||
<h3 className="text-lg font-bold text-card-foreground truncate" title={value}>{value}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{cfg.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="ml-4 flex-shrink-0 p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Summary bar ── */}
|
||||
{!isLoading && data.length > 0 && (
|
||||
<div className="flex items-center gap-6 px-6 py-2.5 bg-white/[0.02] border-b border-white/[0.06] flex-shrink-0 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-semibold text-slate-200">{data.length.toLocaleString()}</span>
|
||||
{" "}{data.length === 1 ? "record" : "records"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Total Download:{" "}
|
||||
<span className="text-cyan-400 font-semibold font-mono">{fmtBytes(totalDownload)}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Total Upload:{" "}
|
||||
<span className="text-emerald-400 font-semibold font-mono">{fmtBytes(totalUpload)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
|
||||
<p className="text-slate-400 text-sm font-medium">Loading data from MongoDB...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-red-400">
|
||||
<AlertCircle className="h-8 w-8" />
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-slate-500">
|
||||
<Database className="h-8 w-8" />
|
||||
<p className="text-base font-semibold text-slate-400">No Data</p>
|
||||
<p className="text-sm">No records found for this entry in MongoDB</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-center whitespace-nowrap text-sm table-fixed">
|
||||
<thead className="sticky top-0 bg-[#111827] z-10">
|
||||
<TableHead cols={cols} />
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{pagedData.map((row, i) => (
|
||||
<TableRow key={pageStart + i} row={row} group={group} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{!isLoading && !error && data.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<Pagination
|
||||
currentPage={safePage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={data.length}
|
||||
startIndex={pageStart}
|
||||
endIndex={pageStart + PAGE_SIZE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
user: any;
|
||||
setUser: (user: any) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AccountNameTab({ user, setUser, onClose }: Props) {
|
||||
const [newAccountName, setNewAccountName] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-account-name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newAccountName }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Account name changed successfully!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, account_name: data.newAccountName });
|
||||
}
|
||||
} else {
|
||||
setErrorMsg(data.error || "Failed to change account name.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "A system error occurred.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">New Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAccountName}
|
||||
onChange={(e) => setNewAccountName(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none"
|
||||
placeholder="e.g., Super Admin Budi"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Current Password (Authorization)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter password to continue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newAccountName || !currentPassword || isSubmitting}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmitting ? "Saving..." : "Save Account Name"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { useState, useEffect } from "react";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Eye, EyeOff, Image as ImageIcon, Upload, Trash2 } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
|
||||
import AccountNameTab from "./AccountNameTab";
|
||||
import ProfilePictureTab from "./ProfilePictureTab";
|
||||
import UsernameTab from "./UsernameTab";
|
||||
import PasswordTab from "./PasswordTab";
|
||||
import ViewAsHistoryTab from "./ViewAsHistoryTab";
|
||||
|
||||
type Tab = 'account_name' | 'username' | 'password' | 'profile_picture' | 'view_as_history';
|
||||
|
||||
export function AccountSettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -14,603 +23,106 @@ export function AccountSettingsModal({
|
||||
user: any;
|
||||
setUser: (user: any) => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'account_name' | 'username' | 'password' | 'profile_picture'>('account_name');
|
||||
|
||||
// Password state
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Username state
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [usernameErrorMsg, setUsernameErrorMsg] = useState("");
|
||||
const [usernameSuccessMsg, setUsernameSuccessMsg] = useState("");
|
||||
const [isSubmittingUsername, setIsSubmittingUsername] = useState(false);
|
||||
|
||||
// Account Name state
|
||||
const [newAccountName, setNewAccountName] = useState("");
|
||||
const [accountNameErrorMsg, setAccountNameErrorMsg] = useState("");
|
||||
const [accountNameSuccessMsg, setAccountNameSuccessMsg] = useState("");
|
||||
const [isSubmittingAccountName, setIsSubmittingAccountName] = useState(false);
|
||||
|
||||
// Profile Picture state
|
||||
const [profilePicFile, setProfilePicFile] = useState<File | null>(null);
|
||||
const [profilePicErrorMsg, setProfilePicErrorMsg] = useState("");
|
||||
const [profilePicSuccessMsg, setProfilePicSuccessMsg] = useState("");
|
||||
const [isUploadingPic, setIsUploadingPic] = useState(false);
|
||||
|
||||
// Change Password visibility state
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
// Real-time checks for password strength validation
|
||||
const isMinLength = newPassword.length >= 6;
|
||||
const hasUppercase = /[A-Z]/.test(newPassword);
|
||||
const hasLowercase = /[a-z]/.test(newPassword);
|
||||
const hasNumber = /[0-9]/.test(newPassword);
|
||||
const passwordsMatch = newPassword === confirmPassword && confirmPassword.length > 0;
|
||||
const isPasswordValid = isMinLength && hasUppercase && hasLowercase && hasNumber && passwordsMatch;
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isPasswordValid) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Password changed successfully!");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
setErrorMsg(data.error || "Failed to change password.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeUsername = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmittingUsername(true);
|
||||
setUsernameErrorMsg("");
|
||||
setUsernameSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-username', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newUsername }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setUsernameSuccessMsg("Username changed successfully!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, username: data.newUsername });
|
||||
}
|
||||
} else {
|
||||
setUsernameErrorMsg(data.error || "Failed to change username.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setUsernameErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsSubmittingUsername(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeAccountName = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmittingAccountName(true);
|
||||
setAccountNameErrorMsg("");
|
||||
setAccountNameSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-account-name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newAccountName }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setAccountNameSuccessMsg("Account name changed successfully!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, account_name: data.newAccountName });
|
||||
}
|
||||
} else {
|
||||
setAccountNameErrorMsg(data.error || "Failed to change account name.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAccountNameErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsSubmittingAccountName(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadProfilePicture = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!profilePicFile) return;
|
||||
|
||||
setIsUploadingPic(true);
|
||||
setProfilePicErrorMsg("");
|
||||
setProfilePicSuccessMsg("");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', profilePicFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProfilePicSuccessMsg("Profile picture updated successfully!");
|
||||
setProfilePicFile(null);
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: data.profile_picture });
|
||||
}
|
||||
} else {
|
||||
setProfilePicErrorMsg(data.error || "Failed to upload profile picture.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProfilePicErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsUploadingPic(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveProfilePicture = async () => {
|
||||
setIsUploadingPic(true);
|
||||
setProfilePicErrorMsg("");
|
||||
setProfilePicSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/remove-profile-picture', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProfilePicSuccessMsg("Profile picture deleted successfully!");
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: undefined });
|
||||
}
|
||||
} else {
|
||||
setProfilePicErrorMsg(data.error || "Failed to delete profile picture.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProfilePicErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
} finally {
|
||||
setIsUploadingPic(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset state when modal closes or opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
setUsernameErrorMsg("");
|
||||
setUsernameSuccessMsg("");
|
||||
setAccountNameErrorMsg("");
|
||||
setAccountNameSuccessMsg("");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setNewUsername("");
|
||||
setNewAccountName("");
|
||||
setProfilePicFile(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
const [activeTab, setActiveTab] = useState<Tab>('account_name');
|
||||
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'TENANT_ADMIN';
|
||||
const canViewHistory = isAdmin || user?.role === 'SOC_ANALYST';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Account Settings">
|
||||
|
||||
<div className="flex mb-6 border-b border-border/50">
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Account Settings"
|
||||
className="sm:max-w-3xl sm:w-full h-[520px]"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-6 h-[430px]">
|
||||
{/* Left Sidebar Layout (Fixed Width & Height) */}
|
||||
<div className="w-full md:w-[180px] flex flex-row md:flex-col gap-1 border-b md:border-b-0 md:border-r border-border/50 pb-4 md:pb-0 md:pr-4 shrink-0 overflow-x-auto md:overflow-x-visible custom-scrollbar h-full">
|
||||
<button
|
||||
onClick={() => setActiveTab('account_name')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'account_name' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left",
|
||||
activeTab === 'account_name'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
Change Name
|
||||
{activeTab === 'account_name' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('profile_picture')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'profile_picture' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left",
|
||||
activeTab === 'profile_picture'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
Foto Profil
|
||||
{activeTab === 'profile_picture' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
Profile Picture
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('username')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'username' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left",
|
||||
activeTab === 'username'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
Change Username
|
||||
{activeTab === 'username' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
Username
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={cn(
|
||||
"flex-1 pb-3 text-sm font-medium transition-colors relative",
|
||||
activeTab === 'password' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left",
|
||||
activeTab === 'password'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
Change Password
|
||||
{activeTab === 'password' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary rounded-t-full shadow-[0_-2px_10px_rgba(59,130,246,0.5)]" />
|
||||
)}
|
||||
Password
|
||||
</button>
|
||||
|
||||
{canViewHistory && (
|
||||
<button
|
||||
onClick={() => setActiveTab('view_as_history')}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left whitespace-nowrap",
|
||||
activeTab === 'view_as_history'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
View As History
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'profile_picture' ? (
|
||||
<div className="space-y-4">
|
||||
{profilePicErrorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{profilePicErrorMsg}
|
||||
</div>
|
||||
)}
|
||||
{profilePicSuccessMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{profilePicSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
{/* Right Content Pane (Fixed Height & Scrollable for strict Rule 9 size consistency) */}
|
||||
<div className="flex-1 min-w-0 h-full overflow-y-auto pr-2 custom-scrollbar">
|
||||
{activeTab === 'account_name' && (
|
||||
<AccountNameTab user={user} setUser={setUser} onClose={onClose} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="h-24 w-24 rounded-full bg-gradient-to-tr from-primary to-blue-400 p-0.5 shadow-lg shadow-primary/20 mb-4 overflow-hidden relative group">
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-background overflow-hidden relative">
|
||||
{user?.profile_picture ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-white uppercase">
|
||||
{user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : (user?.role === 'SUPER_ADMIN' ? 'A' : 'V'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 w-full">
|
||||
<form onSubmit={handleUploadProfilePicture} className="flex-1 flex flex-col space-y-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setProfilePicFile(e.target.files ? e.target.files[0] : null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<div className="flex items-center justify-center w-full px-4 py-2 border border-dashed border-border rounded-lg bg-secondary/30 hover:bg-secondary/50 transition-colors">
|
||||
<Upload className="w-4 h-4 mr-2 text-slate-400" />
|
||||
<span className="text-sm text-slate-300 truncate">
|
||||
{profilePicFile ? profilePicFile.name : "Choose Image File"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!profilePicFile || isUploadingPic}
|
||||
className="w-full rounded-lg px-4 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center justify-center"
|
||||
>
|
||||
{isUploadingPic ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isUploadingPic ? "Mengunggah..." : "Unggah Foto"}
|
||||
</button>
|
||||
</form>
|
||||
{activeTab === 'profile_picture' && (
|
||||
<ProfilePictureTab user={user} setUser={setUser} onClose={onClose} />
|
||||
)}
|
||||
|
||||
{user?.profile_picture && (
|
||||
<button
|
||||
onClick={handleRemoveProfilePicture}
|
||||
disabled={isUploadingPic}
|
||||
className="flex flex-col items-center justify-center px-4 py-2 border border-red-500/30 rounded-lg text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors shrink-0 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-5 h-5 mb-1" />
|
||||
<span className="text-[10px] font-medium">Delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'account_name' ? (
|
||||
<form onSubmit={handleChangeAccountName} className="space-y-4">
|
||||
{accountNameErrorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{accountNameErrorMsg}
|
||||
</div>
|
||||
)}
|
||||
{accountNameSuccessMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{accountNameSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'username' && (
|
||||
<UsernameTab user={user} setUser={setUser} onClose={onClose} />
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Nama Tampilan Baru</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAccountName}
|
||||
onChange={(e) => setNewAccountName(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none"
|
||||
placeholder="e.g., Super Admin Budi"
|
||||
/>
|
||||
</div>
|
||||
{activeTab === 'password' && (
|
||||
<PasswordTab onClose={onClose} />
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini (Otorisasi)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter password to continue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newAccountName || !currentPassword || isSubmittingAccountName}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmittingAccountName ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmittingAccountName ? "Saving..." : "Save Account Name"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : activeTab === 'username' ? (
|
||||
<form onSubmit={handleChangeUsername} className="space-y-4">
|
||||
{usernameErrorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{usernameErrorMsg}
|
||||
</div>
|
||||
)}
|
||||
{usernameSuccessMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{usernameSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Username Baru</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none"
|
||||
placeholder="Masukkan username baru (min. 4 karakter)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini (Otorisasi)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter password to continue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newUsername || !currentPassword || isSubmittingUsername}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmittingUsername ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmittingUsername ? "Saving..." : "Save Username"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-xs text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Saat Ini</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Masukkan password saat ini"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Password Baru</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNew ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showNew ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Konfirmasi Password Baru</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Re-enter new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showConfirm ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Checklist */}
|
||||
<div className="rounded-xl bg-gradient-to-br from-secondary/40 to-background p-4 border border-border/50 shadow-inner space-y-3">
|
||||
<span className="text-xs font-semibold text-slate-200">Syarat Kekuatan Password:</span>
|
||||
<ul className="text-xs space-y-2">
|
||||
<li className={cn("flex items-center transition-colors duration-200", isMinLength ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{isMinLength ? "✓" : "○"}</span> Minimal 6 karakter
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasUppercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasUppercase ? "✓" : "○"}</span> Mengandung huruf besar (A-Z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasLowercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasLowercase ? "✓" : "○"}</span> Mengandung huruf kecil (a-z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasNumber ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasNumber ? "✓" : "○"}</span> Mengandung angka (0-9)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", passwordsMatch ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{passwordsMatch ? "✓" : "○"}</span> Password cocok
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isPasswordValid || isSubmitting}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmitting ? "Saving..." : "Save Password"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === 'view_as_history' && canViewHistory && (
|
||||
<ViewAsHistoryTab onClose={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { ReactNode, useState, useEffect } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { getViewAsStatusSync, stopViewAs } from "@/lib/admin-api";
|
||||
import { AlertTriangle, LogOut, Clock, ShieldAlert, Loader2 } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
|
||||
export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [viewAs, setViewAs] = useState<{ active: boolean; agent_label?: string; agent_uuid?: string }>({ active: false });
|
||||
const [user, setUser] = useState<any>(null);
|
||||
|
||||
// Timeout dialog state
|
||||
const [showTimeoutWarning, setShowTimeoutWarning] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState(120);
|
||||
const [isRenewing, setIsRenewing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setViewAs(getViewAsStatusSync());
|
||||
|
||||
// Fetch initial user session to get token exp time
|
||||
fetch('/api/auth/me')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) setUser(data.user);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Background timer to watch token expiration
|
||||
useEffect(() => {
|
||||
if (!user || !user.exp) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const remaining = user.exp - now;
|
||||
|
||||
if (remaining <= 120 && remaining > 0) {
|
||||
setTimeLeft(remaining);
|
||||
setShowTimeoutWarning(true);
|
||||
} else if (remaining <= 0) {
|
||||
// Session expired, force logout immediately
|
||||
clearInterval(timer);
|
||||
setShowTimeoutWarning(false);
|
||||
fetch('/api/auth/logout', { method: 'POST' })
|
||||
.then(() => {
|
||||
window.location.href = '/login';
|
||||
});
|
||||
} else {
|
||||
setShowTimeoutWarning(false);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [user]);
|
||||
|
||||
const handleRenewSession = async () => {
|
||||
setIsRenewing(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/renew', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.ok && json.user) {
|
||||
setUser(json.user);
|
||||
setShowTimeoutWarning(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to renew session:", err);
|
||||
} finally {
|
||||
setIsRenewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
} catch (err) {
|
||||
console.error("Failed to logout:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCountdown = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s.toString().padStart(2, '0')}s`;
|
||||
};
|
||||
|
||||
const handleExit = async () => {
|
||||
await stopViewAs();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-transparent transition-colors duration-500 relative">
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden w-full min-w-0 relative transition-all duration-500 ease-in-out">
|
||||
{viewAs.active && (
|
||||
<div className="bg-gradient-to-r from-amber-600/90 via-amber-700/90 to-amber-900/90 border-b border-amber-500/30 px-6 py-2.5 flex items-center justify-between text-white shadow-lg backdrop-blur-sm z-30 animate-pulse-subtle">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-300 animate-bounce" />
|
||||
<span>
|
||||
You are currently viewing data as Network Agent: <strong className="text-amber-200">{viewAs.agent_label || "Unknown Agent"}</strong> <span className="text-xs text-white/70">({viewAs.agent_uuid})</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExit}
|
||||
className="flex items-center gap-1.5 px-3 py-1 bg-white/10 hover:bg-white/20 active:bg-white/30 border border-white/20 rounded-md text-xs font-semibold tracking-wide transition-all shadow-sm shrink-0"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
Exit View Mode
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-transparent p-4 md:p-6 transition-all duration-500 ease-in-out w-full relative">
|
||||
<div className="mx-auto w-full max-w-7xl space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Premium Session Timeout Warning Modal */}
|
||||
<Modal isOpen={showTimeoutWarning} onClose={() => {}} title="Session Security Alert">
|
||||
<div className="space-y-5 text-xs text-slate-300">
|
||||
<div className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
||||
<ShieldAlert className="w-5 h-5 text-amber-400 shrink-0" />
|
||||
<p className="font-medium text-slate-200">
|
||||
For your security, your active dashboard session will automatically expire due to inactivity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-6 bg-slate-900/40 border border-white/5 rounded-2xl relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-blue-500/5 blur-3xl rounded-full" />
|
||||
<Clock className="w-8 h-8 text-blue-400 mb-2 relative animate-pulse" />
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest relative">Remaining Time</span>
|
||||
<span className="text-3xl font-black text-white font-mono tracking-tight mt-1 relative">
|
||||
{formatCountdown(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-xl transition-all"
|
||||
>
|
||||
Logout Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRenewSession}
|
||||
disabled={isRenewing}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/90 active:scale-[0.98] text-primary-foreground px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isRenewing ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Keep Me Logged In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PasswordTab({ onClose }: Props) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const isMinLength = newPassword.length >= 6;
|
||||
const hasUppercase = /[A-Z]/.test(newPassword);
|
||||
const hasLowercase = /[a-z]/.test(newPassword);
|
||||
const hasNumber = /[0-9]/.test(newPassword);
|
||||
const passwordsMatch = newPassword === confirmPassword && confirmPassword.length > 0;
|
||||
const isPasswordValid = isMinLength && hasUppercase && hasLowercase && hasNumber && passwordsMatch;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isPasswordValid) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Password changed successfully!");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
setErrorMsg(data.error || "Failed to change password.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "A system error occurred.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Current Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNew ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showNew ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Confirm New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Re-enter new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showConfirm ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-gradient-to-br from-secondary/40 to-background p-4 border border-border/50 shadow-inner space-y-3">
|
||||
<span className="text-xs font-semibold text-slate-200">Password Strength Requirements:</span>
|
||||
<ul className="text-xs space-y-2">
|
||||
<li className={cn("flex items-center transition-colors duration-200", isMinLength ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{isMinLength ? "✓" : "○"}</span> At least 6 characters
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasUppercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasUppercase ? "✓" : "○"}</span> Contains uppercase letter (A-Z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasLowercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasLowercase ? "✓" : "○"}</span> Contains lowercase letter (a-z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasNumber ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasNumber ? "✓" : "○"}</span> Contains number (0-9)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", passwordsMatch ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{passwordsMatch ? "✓" : "○"}</span> Passwords match
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isPasswordValid || isSubmitting}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmitting ? "Saving..." : "Save Password"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Upload, ImageIcon, Trash2 } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
user: any;
|
||||
setUser: (user: any) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ProfilePictureTab({ user, setUser, onClose }: Props) {
|
||||
const [profilePicFile, setProfilePicFile] = useState<File | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!profilePicFile) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', profilePicFile);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Profile picture updated successfully!");
|
||||
setProfilePicFile(null);
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: data.profile_picture });
|
||||
}
|
||||
} else {
|
||||
setErrorMsg(data.error || "Failed to upload profile picture.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "A system error occurred.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsUploading(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/remove-profile-picture', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Profile picture deleted successfully!");
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: undefined });
|
||||
}
|
||||
} else {
|
||||
setErrorMsg("Failed to delete profile picture.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "A system error occurred.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-xs">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="h-24 w-24 rounded-full bg-gradient-to-tr from-primary to-blue-400 p-0.5 shadow-lg shadow-primary/20 mb-4 overflow-hidden relative group">
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-background overflow-hidden relative">
|
||||
{user?.profile_picture ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-white uppercase">
|
||||
{user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : 'A')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 w-full">
|
||||
<form onSubmit={handleUpload} className="flex-1 flex flex-col space-y-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setProfilePicFile(e.target.files ? e.target.files[0] : null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<div className="flex items-center justify-center w-full px-4 py-2 border border-dashed border-border rounded-lg bg-secondary/30 hover:bg-secondary/50 transition-colors">
|
||||
<Upload className="w-4 h-4 mr-2 text-slate-400" />
|
||||
<span className="text-sm text-slate-300 truncate">
|
||||
{profilePicFile ? profilePicFile.name : "Choose Image File"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!profilePicFile || isUploading}
|
||||
className="w-full rounded-lg px-4 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center justify-center"
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isUploading ? "Uploading..." : "Upload Photo"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{user?.profile_picture && (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={isUploading}
|
||||
className="flex flex-col items-center justify-center px-4 py-2 border border-red-500/30 rounded-lg text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors shrink-0 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-5 h-5 mb-1" />
|
||||
<span className="text-[10px] font-medium">Delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +1,38 @@
|
||||
// src/components/layout/Sidebar.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard, Server, Activity, Globe, Radio, Zap,
|
||||
ShieldAlert, Shield, Lock, AppWindow, Search,
|
||||
Bell, Check, Clock, ChevronDown, User, LogOut, Sun, Moon, AlertTriangle, Info, Calendar, Router
|
||||
} from "lucide-react";
|
||||
import { ChevronDown, Calendar } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useThreats, useEvents } from "@/lib/api";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AccountSettingsModal } from "./AccountSettingsModal";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
|
||||
interface AppNotification {
|
||||
id: string;
|
||||
dbId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
timestamp: string;
|
||||
type: 'threat' | 'event';
|
||||
}
|
||||
|
||||
const navigationGroups = [
|
||||
{
|
||||
title: "OVERVIEW",
|
||||
items: [
|
||||
{ name: "Dashboard", href: "/", icon: LayoutDashboard },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "INFRASTRUCTURE",
|
||||
items: [
|
||||
{ name: "Devices", href: "/devices", icon: Router },
|
||||
{ name: "Agents", href: "/agents", icon: Server },
|
||||
{ name: "Topology", href: "/network-infrastructure", icon: Globe },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "MONITORING",
|
||||
items: [
|
||||
{ name: "Flows", href: "/flows", icon: Activity },
|
||||
{ name: "Apps", href: "/apps", icon: AppWindow },
|
||||
{ name: "DNS", href: "/dns", icon: Radio },
|
||||
{ name: "Events", href: "/events", icon: Zap },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "MANAGEMENT",
|
||||
items: [
|
||||
{ name: "Lookup", href: "/lookup", icon: Search },
|
||||
{ name: "Threats", href: "/threats", icon: ShieldAlert },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "SYSTEM",
|
||||
items: [
|
||||
{ name: "Geography", href: "/geography", icon: Globe },
|
||||
{ name: "Network Intelligence", href: "/network-intelligence", icon: Activity },
|
||||
{ name: "Security Audit", href: "/security-audit", icon: Lock },
|
||||
{ name: "Intelligence", href: "/intelligence", icon: Shield },
|
||||
{ name: "DPI Analytics", href: "/dpi-analytics", icon: Activity },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const TIME_OPTIONS = [
|
||||
{ value: "5m", label: "Last 5 Minutes" },
|
||||
{ value: "10m", label: "Last 10 Minutes" },
|
||||
{ value: "30m", label: "Last 30 Minutes" },
|
||||
{ value: "1h", label: "Last 1 Hour" },
|
||||
{ value: "1d", label: "Last 24 Hours" },
|
||||
{ value: "7d", label: "Last 7 Days" },
|
||||
{ value: "30d", label: "Last 30 Days" },
|
||||
];
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { NAVIGATION_GROUPS, TIME_OPTIONS } from "./SidebarData";
|
||||
import SidebarProfile from "./SidebarProfile";
|
||||
import SidebarNotifications from "./SidebarNotifications";
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { timeRange, setTimeRange } = useTimeFilter();
|
||||
|
||||
// State
|
||||
const [user, setUser] = useState<{ username?: string; account_name?: string; profile_picture?: string; role?: string; agent_uuid?: string } | null>(null);
|
||||
const [isAccountOpen, setIsAccountOpen] = useState(false);
|
||||
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false); // Notifications
|
||||
const [isTimeDropdownOpen, setIsTimeDropdownOpen] = useState(false); // Time Filter
|
||||
const [readIds, setReadIds] = useState<string[]>([]);
|
||||
const [isTimeDropdownOpen, setIsTimeDropdownOpen] = useState(false); // Time Filter dropdown
|
||||
const [intervalText, setIntervalText] = useState<string>("");
|
||||
|
||||
const accountDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const timeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectedTimeOption = TIME_OPTIONS.find(opt => opt.value === timeRange) || TIME_OPTIONS[3];
|
||||
|
||||
// Fetching
|
||||
const { data: threats } = useThreats(30);
|
||||
const { data: events } = useEvents(30);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) setUser(data.user);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
const stored = localStorage.getItem('read_notifications');
|
||||
if (stored) {
|
||||
try { setReadIds(JSON.parse(stored)); } catch {}
|
||||
}
|
||||
|
||||
// Fetch interval
|
||||
fetch('/api/dashboard/data-interval')
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
@@ -133,90 +49,17 @@ export function Sidebar() {
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) setIsOpen(false);
|
||||
if (accountDropdownRef.current && !accountDropdownRef.current.contains(event.target as Node)) setIsAccountDropdownOpen(false);
|
||||
if (timeDropdownRef.current && !timeDropdownRef.current.contains(event.target as Node)) setIsTimeDropdownOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const notifications = useMemo<AppNotification[]>(() => {
|
||||
const list: AppNotification[] = [];
|
||||
if (threats) {
|
||||
for (const t of threats) {
|
||||
list.push({
|
||||
id: `threat-${t.id || t.detected_at}`,
|
||||
dbId: t.id,
|
||||
title: "Ancaman Terdeteksi!",
|
||||
message: `${t.threat_type || "Malicious traffic"} dari IP ${t.ip_address || "unknown"}`,
|
||||
severity: t.severity || "high",
|
||||
timestamp: t.detected_at || "",
|
||||
type: "threat"
|
||||
});
|
||||
}
|
||||
}
|
||||
if (events) {
|
||||
for (const e of events) {
|
||||
const severity = (e.severity || "info").toLowerCase();
|
||||
if (["critical", "high", "medium", "warning"].includes(severity)) {
|
||||
const eventTypeLabel = (e.event_type || 'Sistem')
|
||||
.replace('new.device', 'New Device Discovered')
|
||||
.replace('update.device', 'Device Updated')
|
||||
.replace('server.discovery', 'Server Discovered')
|
||||
.replace('encryption.audit', 'Encryption Alert')
|
||||
.replace(/\./g, ' ');
|
||||
|
||||
list.push({
|
||||
id: `event-${e.id || e.timestamp}`,
|
||||
dbId: e.id,
|
||||
title: `Event: ${eventTypeLabel}`,
|
||||
message: e.message || "",
|
||||
severity: e.severity || "warning",
|
||||
timestamp: e.timestamp || "",
|
||||
type: "event"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
list.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
return list;
|
||||
}, [threats, events]);
|
||||
|
||||
const unreadCount = useMemo(() => notifications.filter(n => !readIds.includes(n.id)).length, [notifications, readIds]);
|
||||
|
||||
const markAllAsRead = () => {
|
||||
const allIds = notifications.map(n => n.id);
|
||||
setReadIds(allIds);
|
||||
localStorage.setItem('read_notifications', JSON.stringify(allIds));
|
||||
};
|
||||
|
||||
const handleNotificationClick = (n: AppNotification) => {
|
||||
if (!readIds.includes(n.id)) {
|
||||
const updated = [...readIds, n.id];
|
||||
setReadIds(updated);
|
||||
localStorage.setItem('read_notifications', JSON.stringify(updated));
|
||||
}
|
||||
setIsOpen(false);
|
||||
router.push(n.type === 'threat' ? `/threats?highlight=${n.dbId}` : `/events?highlight=${n.dbId}`);
|
||||
};
|
||||
|
||||
const getSeverityStyles = (severity: string) => {
|
||||
const s = severity.toLowerCase();
|
||||
if (s.includes('critical') || s.includes('high')) {
|
||||
return { bg: 'bg-red-500/10 hover:bg-red-500/15', border: 'border-red-500/20', text: 'text-red-400', icon: <AlertTriangle className="w-4 h-4 text-red-500 shrink-0" /> };
|
||||
}
|
||||
if (s.includes('med') || s.includes('warning')) {
|
||||
return { bg: 'bg-yellow-500/10 hover:bg-yellow-500/15', border: 'border-yellow-500/20', text: 'text-yellow-400', icon: <AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0" /> };
|
||||
}
|
||||
return { bg: 'bg-blue-500/10 hover:bg-blue-500/15', border: 'border-blue-500/20', text: 'text-blue-400', icon: <Info className="w-4 h-4 text-blue-500 shrink-0" /> };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full max-h-screen flex-col border-r border-white/5 bg-black/20 backdrop-blur-3xl transition-all w-64 shrink-0 relative z-40 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.5)]">
|
||||
{/* Brand & Site Info */}
|
||||
@@ -227,105 +70,97 @@ export function Sidebar() {
|
||||
<span className="text-xl font-bold font-backone text-foreground tracking-tight leading-none">
|
||||
BackOne
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground mt-1 leading-tight tracking-wider whitespace-nowrap">
|
||||
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold mt-1">
|
||||
Deep Package Inspection
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Site Badge */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 rounded bg-secondary/30 px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500 animate-pulse shrink-0" />
|
||||
<span className="truncate flex-1">
|
||||
{process.env.NEXT_PUBLIC_SITE_ID || "Unknown Site"} - {user?.role === 'SUPER_ADMIN' ? 'Admin' : (user?.account_name || user?.agent_uuid || 'User')}
|
||||
</span>
|
||||
</div>
|
||||
{intervalText && (
|
||||
<div className="flex items-center gap-2 rounded bg-blue-500/10 border border-blue-500/20 px-2 py-1.5 text-[10px] text-blue-400 whitespace-nowrap">
|
||||
<Calendar className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate">{intervalText}</span>
|
||||
|
||||
{/* Global Time Filter Widget */}
|
||||
<div className="relative w-full px-2 pb-2" ref={timeDropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsTimeDropdownOpen(!isTimeDropdownOpen)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.05] p-2.5 text-xs text-slate-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Calendar className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
<div className="flex flex-col items-start truncate leading-normal">
|
||||
<span className="font-semibold text-[10px] text-primary uppercase tracking-wider">Time Filter</span>
|
||||
<span className="truncate max-w-[130px]">{selectedTimeOption.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-300", isTimeDropdownOpen && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{isTimeDropdownOpen && (
|
||||
<div className="absolute top-full left-2 right-2 mt-1 rounded-xl border border-white/10 bg-slate-950 p-1.5 shadow-2xl z-50">
|
||||
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{TIME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setTimeRange(opt.value);
|
||||
setIsTimeDropdownOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
timeRange === opt.value
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Filter Dropdown */}
|
||||
<div className="relative mt-1" ref={timeDropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsTimeDropdownOpen(!isTimeDropdownOpen)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded bg-secondary/20 hover:bg-secondary/50 px-2 py-1.5 transition-colors border border-border/20 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-foreground font-medium">{selectedTimeOption.label}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 text-muted-foreground transition-transform duration-1000 ${isTimeDropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isTimeDropdownOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0, marginTop: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1, marginTop: '0.375rem' }}
|
||||
exit={{ height: 0, opacity: 0, marginTop: 0 }}
|
||||
transition={{ duration: 1, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="w-full rounded-md border border-white/10 bg-black/40 flex flex-col p-1">
|
||||
{TIME_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
setTimeRange(option.value as any);
|
||||
setIsTimeDropdownOpen(false);
|
||||
}}
|
||||
className={`flex items-center justify-between rounded px-2 py-1.5 text-xs transition-colors ${
|
||||
timeRange === option.value
|
||||
? 'bg-primary/20 text-primary font-semibold'
|
||||
: 'text-muted-foreground hover:bg-white/5 hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
{timeRange === option.value && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-4 custom-scrollbar min-h-0">
|
||||
<nav className="flex flex-col space-y-6">
|
||||
{navigationGroups.map((group, groupIdx) => {
|
||||
// Filter items based on role
|
||||
const filteredItems = group.items.filter(item => !(user?.role === 'AGENT_VIEWER' && item.name === 'Agents'));
|
||||
if (filteredItems.length === 0) return null;
|
||||
{/* Navigation (Flexible Scrollable Container for Rule 9) */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 custom-scrollbar h-full">
|
||||
{intervalText && (
|
||||
<div className="mb-4 rounded-lg bg-blue-500/5 border border-blue-500/10 p-2 text-center">
|
||||
<span className="text-[9px] uppercase tracking-wider text-blue-400 font-bold block mb-0.5">Telemetry Window</span>
|
||||
<span className="text-[10px] text-slate-400 block font-mono">{intervalText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="flex flex-col gap-5">
|
||||
{NAVIGATION_GROUPS.map((group) => {
|
||||
// Role-based navigation item filtering
|
||||
const allowedItems = group.items.filter((item) => {
|
||||
if (item.href === "/agents") {
|
||||
return user?.role === "SUPER_ADMIN";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (allowedItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={groupIdx} className="flex flex-col space-y-1">
|
||||
<h3 className="px-3 text-[10px] font-bold uppercase tracking-wider text-slate-500 mb-1">
|
||||
<div key={group.title} className="flex flex-col gap-1">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground px-2">
|
||||
{group.title}
|
||||
</h3>
|
||||
{filteredItems.map((item) => {
|
||||
</span>
|
||||
{allowedItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"group flex items-center rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200",
|
||||
"group flex items-center gap-3 rounded-xl px-3 py-2 text-xs font-semibold tracking-wide transition-all duration-300",
|
||||
isActive
|
||||
? "bg-primary/15 text-primary shadow-[inset_2px_0_0_0_hsl(var(--primary))]"
|
||||
: "text-muted-foreground hover:bg-secondary/40 hover:text-foreground"
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary font-bold shadow-md shadow-primary/5"
|
||||
: "text-slate-400 hover:bg-white/[0.02] hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
<Icon
|
||||
className={cn(
|
||||
"mr-3 h-4 w-4 shrink-0 transition-transform",
|
||||
"h-4 w-4 transition-transform duration-500",
|
||||
isActive ? "text-primary" : "text-muted-foreground group-hover:text-foreground group-hover:scale-110"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
@@ -343,126 +178,8 @@ export function Sidebar() {
|
||||
{/* Footer (Account & Notifications) */}
|
||||
<div className="mt-auto p-4 border-t border-white/5 bg-white/5 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Account Settings Dropdown */}
|
||||
<div className="relative flex-1 min-w-0" ref={accountDropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsAccountDropdownOpen(!isAccountDropdownOpen)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden">
|
||||
{user?.profile_picture ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold text-white uppercase text-xs">
|
||||
{user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : (user?.role === 'SUPER_ADMIN' ? 'A' : 'V'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start overflow-hidden text-left min-w-0 flex-1">
|
||||
<span className="text-sm font-semibold text-foreground truncate w-full">{user?.account_name || user?.username || 'Loading...'}</span>
|
||||
<span className="text-[10px] text-primary truncate w-full">{user?.role === 'SUPER_ADMIN' ? 'Admin' : 'Standard User'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isAccountDropdownOpen && (
|
||||
<div className="absolute bottom-0 left-full ml-4 w-56 rounded-xl border border-border bg-card/95 backdrop-blur-xl p-2 shadow-2xl z-50 animate-fade-in">
|
||||
<div className="flex flex-col items-center justify-center p-3 mb-2 border-b border-border/50">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden mb-2">
|
||||
{user?.profile_picture ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold text-white uppercase text-lg">
|
||||
{user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : (user?.role === 'SUPER_ADMIN' ? 'A' : 'V'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-foreground text-center break-words w-full leading-tight">{user?.account_name || user?.username || 'Loading...'}</span>
|
||||
<span className="text-xs text-primary mt-1 text-center font-medium">{user?.role === 'SUPER_ADMIN' ? 'Admin' : 'Standard User'}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAccountDropdownOpen(false);
|
||||
setIsAccountOpen(true);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<User className="h-4 w-4" /> Account Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
setIsAccountDropdownOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4 shrink-0" /> : <Moon className="h-4 w-4 shrink-0" />}
|
||||
<span className="truncate">{theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}</span>
|
||||
</button>
|
||||
<div className="h-px bg-border/50 my-1 mx-2" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" /> Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="relative shrink-0" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative flex h-10 w-10 items-center justify-center rounded-xl text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute right-2 top-2 flex h-3 w-3 items-center justify-center rounded-full bg-red-500 text-[8px] font-bold text-[#ffffff] shadow-lg ring-2 ring-transparent">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-0 left-full ml-4 w-96 rounded-2xl border border-border bg-card/95 backdrop-blur-xl p-3 shadow-2xl z-50 animate-fade-in">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-2">
|
||||
<span className="text-sm font-semibold text-foreground">Notifikasi</span>
|
||||
{unreadCount > 0 && (
|
||||
<button onClick={markAllAsRead} className="text-xs text-primary hover:text-primary/80">Tandai dibaca</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">Tidak ada notifikasi</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const isRead = readIds.includes(n.id);
|
||||
const styles = getSeverityStyles(n.severity);
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`flex gap-2 p-2 rounded-lg border transition-all cursor-pointer ${styles.bg} ${styles.border} ${!isRead ? 'ring-1 ring-primary/25 opacity-100' : 'opacity-60'}`}
|
||||
>
|
||||
{styles.icon}
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className={`text-xs font-semibold ${styles.text} line-clamp-1`}>{n.title}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground line-clamp-2">{n.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SidebarProfile user={user} onOpenSettings={() => setIsAccountOpen(true)} />
|
||||
<SidebarNotifications />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
LayoutDashboard, Server, Activity, Globe, Radio, Zap,
|
||||
ShieldAlert, Shield, Lock, AppWindow, Search, Router
|
||||
} from "lucide-react";
|
||||
|
||||
export const NAVIGATION_GROUPS = [
|
||||
{
|
||||
title: "OVERVIEW",
|
||||
items: [
|
||||
{ name: "Dashboard", href: "/", icon: LayoutDashboard },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "INFRASTRUCTURE",
|
||||
items: [
|
||||
{ name: "Devices", href: "/devices", icon: Router },
|
||||
{ name: "Agents", href: "/agents", icon: Server },
|
||||
{ name: "Topology", href: "/network-infrastructure", icon: Globe },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "MONITORING",
|
||||
items: [
|
||||
{ name: "Flows", href: "/flows", icon: Activity },
|
||||
{ name: "Apps", href: "/apps", icon: AppWindow },
|
||||
{ name: "DNS", href: "/dns", icon: Radio },
|
||||
{ name: "Events", href: "/events", icon: Zap },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "MANAGEMENT",
|
||||
items: [
|
||||
{ name: "Lookup", href: "/lookup", icon: Search },
|
||||
{ name: "Threats", href: "/threats", icon: ShieldAlert },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "SYSTEM",
|
||||
items: [
|
||||
{ name: "Geo Traffic", href: "/geography", icon: Globe },
|
||||
{ name: "Traffic Categories", href: "/network-intelligence", icon: Activity },
|
||||
{ name: "Encryption Audit (TLS)", href: "/security-audit", icon: Lock },
|
||||
{ name: "Threat Intelligence Feeds", href: "/intelligence", icon: Shield },
|
||||
{ name: "BackOne Metadata", href: "/dpi-analytics", icon: Activity },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const TIME_OPTIONS = [
|
||||
{ value: "5m", label: "Last 5 Minutes" },
|
||||
{ value: "30m", label: "Last 30 Minutes" },
|
||||
{ value: "1h", label: "Last 1 Hour" },
|
||||
{ value: "1d", label: "Last 24 Hours" },
|
||||
{ value: "7d", label: "Last 7 Days" },
|
||||
] as const;
|
||||
@@ -0,0 +1,93 @@
|
||||
// src/components/layout/SidebarNotifications.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Bell, AlertTriangle, Info } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useNotifications, AppNotification } from "./useNotifications";
|
||||
|
||||
export default function SidebarNotifications() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const { notifications, unreadCount, readIds, markAllAsRead, markAsRead } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleNotificationClick = (n: AppNotification) => {
|
||||
markAsRead(n.id);
|
||||
setIsOpen(false);
|
||||
router.push(n.type === 'threat' ? `/threats?highlight=${n.dbId}` : `/events?highlight=${n.dbId}`);
|
||||
};
|
||||
|
||||
const getSeverityStyles = (severity: string) => {
|
||||
const s = severity.toLowerCase();
|
||||
if (s.includes('critical') || s.includes('high')) {
|
||||
return { bg: 'bg-red-500/10 hover:bg-red-500/15', border: 'border-red-500/20', text: 'text-red-400', icon: <AlertTriangle className="w-4 h-4 text-red-500 shrink-0" /> };
|
||||
}
|
||||
if (s.includes('med') || s.includes('warning')) {
|
||||
return { bg: 'bg-yellow-500/10 hover:bg-yellow-500/15', border: 'border-yellow-500/20', text: 'text-yellow-400', icon: <AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0" /> };
|
||||
}
|
||||
return { bg: 'bg-blue-500/10 hover:bg-blue-500/15', border: 'border-blue-500/20', text: 'text-blue-400', icon: <Info className="w-4 h-4 text-blue-500 shrink-0" /> };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative flex h-10 w-10 items-center justify-center rounded-xl text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute right-2 top-2 flex h-3 w-3 items-center justify-center rounded-full bg-red-500 text-[8px] font-bold text-[#ffffff] shadow-lg ring-2 ring-transparent">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-0 left-full ml-4 w-96 rounded-2xl border border-border bg-card/95 backdrop-blur-xl p-3 shadow-2xl z-50 animate-fade-in">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-2">
|
||||
<span className="text-sm font-semibold text-foreground">Notifikasi</span>
|
||||
{unreadCount > 0 && (
|
||||
<button onClick={markAllAsRead} className="text-xs text-primary hover:text-primary/80">Tandai dibaca</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">Tidak ada notifikasi</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const isRead = readIds.includes(n.id);
|
||||
const styles = getSeverityStyles(n.severity);
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`flex gap-2 p-2 rounded-lg border transition-all cursor-pointer ${styles.bg} ${styles.border} ${!isRead ? 'ring-1 ring-primary/25 opacity-100' : 'opacity-60'}`}
|
||||
>
|
||||
{styles.icon}
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className={`text-xs font-semibold ${styles.text} line-clamp-1`}>{n.title}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground line-clamp-2">{n.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// src/components/layout/SidebarProfile.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { User as UserIcon, Sun, Moon, LogOut } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
const ROLE_DISPLAY_NAMES: Record<string, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
TENANT_ADMIN: 'Tenant Admin',
|
||||
SOC_ANALYST: 'SOC Analyst',
|
||||
ENGINEER: 'Engineer',
|
||||
AGENT_VIEWER: 'Agent Viewer',
|
||||
};
|
||||
|
||||
const getRoleDisplayName = (role?: string) => {
|
||||
if (!role) return 'Standard User';
|
||||
return ROLE_DISPLAY_NAMES[role] || 'Standard User';
|
||||
};
|
||||
|
||||
interface ProfileProps {
|
||||
user: any;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
|
||||
const accountDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (accountDropdownRef.current && !accountDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsAccountDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const roleLabel = getRoleDisplayName(user?.role);
|
||||
const initial = user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : (user?.role === 'SUPER_ADMIN' ? 'A' : 'V'));
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0" ref={accountDropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsAccountDropdownOpen(!isAccountDropdownOpen)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden">
|
||||
{user?.profile_picture ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold text-white uppercase text-xs">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start overflow-hidden text-left min-w-0 flex-1">
|
||||
<span className="text-sm font-semibold text-foreground truncate w-full">{user?.account_name || user?.username || 'Loading...'}</span>
|
||||
<span className="text-[10px] text-primary truncate w-full">{roleLabel}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isAccountDropdownOpen && (
|
||||
<div className="absolute bottom-0 left-full ml-4 w-56 rounded-xl border border-border bg-card/95 backdrop-blur-xl p-2 shadow-2xl z-50 animate-fade-in">
|
||||
<div className="flex flex-col items-center justify-center p-3 mb-2 border-b border-border/50">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden mb-2">
|
||||
{user?.profile_picture ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold text-white uppercase text-lg">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-foreground text-center break-words w-full leading-tight">{user?.account_name || user?.username || 'Loading...'}</span>
|
||||
<span className="text-xs text-primary mt-1 text-center font-medium">{roleLabel}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAccountDropdownOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" /> Account Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
setIsAccountDropdownOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4 shrink-0" /> : <Moon className="h-4 w-4 shrink-0" />}
|
||||
<span className="truncate">{theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}</span>
|
||||
</button>
|
||||
<div className="h-px bg-border/50 my-1 mx-2" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" /> Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
user: any;
|
||||
setUser: (user: any) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function UsernameTab({ user, setUser, onClose }: Props) {
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-username', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newUsername }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Username changed successfully!");
|
||||
setCurrentPassword("");
|
||||
setNewUsername("");
|
||||
if (user) {
|
||||
setUser({ ...user, username: data.newUsername });
|
||||
}
|
||||
} else {
|
||||
setErrorMsg(data.error || "Failed to change username.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "A system error occurred.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">New Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none"
|
||||
placeholder="Enter new username (min. 4 characters)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Current Password (Authorization)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter password to continue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newUsername || !currentPassword || isSubmitting}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmitting ? "Saving..." : "Save Username"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2, Server } from "lucide-react";
|
||||
import { Pagination } from "../ui/Pagination";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ViewAsHistoryTab({ onClose }: Props) {
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetch('/api/auth/admin/view-as/logs', {
|
||||
headers: getViewAsHeaders()
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
if (json.ok && json.data) {
|
||||
setLogs(json.data);
|
||||
} else {
|
||||
setError(json.error || "Failed to fetch logs");
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err.message || "An error occurred");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const totalPages = Math.ceil(logs.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = logs.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-2">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span className="text-xs text-slate-400">Loading audit history...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-xs text-red-400 border border-red-500/20 text-center">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-white/5 rounded-2xl">
|
||||
<Server className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<h4 className="text-slate-300 font-bold text-sm mb-1">No Access History</h4>
|
||||
<p className="text-slate-400 font-medium max-w-md mx-auto text-[11px]">
|
||||
No administrators have entered "View As" session mode yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number | undefined, hasEnd: any) => {
|
||||
if (!hasEnd) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-black tracking-wide uppercase bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<span className="w-1.2 h-1.2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Active
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (seconds === undefined) return "-";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="rounded-xl border border-white/10 bg-black/40 overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="grid grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1.3fr)_minmax(0,1.8fr)_minmax(0,1fr)] gap-0 bg-slate-900/80 text-slate-300 border-b border-white/10 p-3 font-semibold text-center">
|
||||
<div>Time</div>
|
||||
<div>Admin</div>
|
||||
<div>Agent Label</div>
|
||||
<div>Agent UUID</div>
|
||||
<div>Duration</div>
|
||||
</div>
|
||||
{/* Table Rows */}
|
||||
<div className="divide-y divide-white/5 max-h-[40vh] overflow-y-auto">
|
||||
{paginated.map((log: any, i: number) => {
|
||||
const timeStr = log.timestamp ? new Date(log.timestamp).toLocaleString() : '-';
|
||||
return (
|
||||
<div key={i} className="grid grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_minmax(0,1.3fr)_minmax(0,1.8fr)_minmax(0,1fr)] gap-0 p-3 hover:bg-blue-500/5 transition-colors text-center items-center">
|
||||
<div className="text-slate-400 font-mono text-[11px]">{timeStr}</div>
|
||||
<div className="text-slate-200 font-semibold">{log.admin_username}</div>
|
||||
<div className="text-blue-300 font-semibold">{log.agent_label}</div>
|
||||
<div className="text-slate-400 font-mono text-[11px]">{log.agent_uuid}</div>
|
||||
<div className="flex items-center justify-center font-medium text-slate-300">
|
||||
{formatDuration(log.duration, log.end_timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={logs.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useThreats, useEvents } from '@/lib/api';
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
dbId: number;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
timestamp: string;
|
||||
type: 'threat' | 'event';
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const { data: threats } = useThreats(30);
|
||||
const { data: events } = useEvents(30);
|
||||
const [readIds, setReadIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('read_notifications');
|
||||
if (stored) {
|
||||
try {
|
||||
setReadIds(JSON.parse(stored));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse read notifications', e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const notifications = useMemo<AppNotification[]>(() => {
|
||||
const list: AppNotification[] = [];
|
||||
if (threats) {
|
||||
for (const t of threats) {
|
||||
list.push({
|
||||
id: `threat-${t.id || t.detected_at}`,
|
||||
dbId: t.id,
|
||||
title: "Threat Detected!",
|
||||
message: `${t.threat_type || "Malicious traffic"} from IP ${t.ip_address || "unknown"}`,
|
||||
severity: t.severity || "high",
|
||||
timestamp: t.detected_at || "",
|
||||
type: "threat"
|
||||
});
|
||||
}
|
||||
}
|
||||
if (events) {
|
||||
for (const e of events) {
|
||||
const severity = (e.severity || "info").toLowerCase();
|
||||
if (["critical", "high", "medium", "warning"].includes(severity)) {
|
||||
const eventTypeLabel = (e.event_type || 'Sistem')
|
||||
.replace('new.device', 'New Device Discovered')
|
||||
.replace('update.device', 'Device Updated')
|
||||
.replace('server.discovery', 'Server Discovered')
|
||||
.replace('encryption.audit', 'Encryption Alert')
|
||||
.replace(/\./g, ' ');
|
||||
|
||||
list.push({
|
||||
id: `event-${e.id || e.timestamp}`,
|
||||
dbId: e.id,
|
||||
title: `Event: ${eventTypeLabel}`,
|
||||
message: e.message || "",
|
||||
severity: e.severity || "warning",
|
||||
timestamp: e.timestamp || "",
|
||||
type: "event"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
list.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||
return list;
|
||||
}, [threats, events]);
|
||||
|
||||
const unreadCount = useMemo(() => {
|
||||
return notifications.filter(n => !readIds.includes(n.id)).length;
|
||||
}, [notifications, readIds]);
|
||||
|
||||
const markAllAsRead = () => {
|
||||
const allIds = notifications.map(n => n.id);
|
||||
setReadIds(allIds);
|
||||
localStorage.setItem('read_notifications', JSON.stringify(allIds));
|
||||
};
|
||||
|
||||
const markAsRead = (id: string) => {
|
||||
if (!readIds.includes(id)) {
|
||||
const updated = [...readIds, id];
|
||||
setReadIds(updated);
|
||||
localStorage.setItem('read_notifications', JSON.stringify(updated));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
notifications,
|
||||
unreadCount,
|
||||
readIds,
|
||||
markAllAsRead,
|
||||
markAsRead
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// src/components/security-audit/DeviceRiskTable.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { SecurityDevice } from "@/lib/api";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { ShieldCheck, ShieldAlert, Loader2, Info, ChevronDown } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
const RISK_CONFIG: Record<string, any> = {
|
||||
Vulnerable: { color: "text-red-400", bg: "bg-red-500/20 ring-red-500/40", icon: <ShieldAlert className="w-3.5 h-3.5" />, dot: "bg-red-500" },
|
||||
Moderate: { color: "text-orange-400", bg: "bg-orange-500/20 ring-orange-500/40", icon: <ShieldAlert className="w-3.5 h-3.5" />, dot: "bg-orange-500" },
|
||||
Safe: { color: "text-green-400", bg: "bg-green-500/20 ring-green-500/40", icon: <ShieldCheck className="w-3.5 h-3.5" />, dot: "bg-green-500" },
|
||||
} as const;
|
||||
|
||||
const FilterSelect = ({ value, onChange, options, placeholder }: { value: string; onChange: (val: string) => void; options: string[]; placeholder: string }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const isActive = value !== "All";
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[140px] mx-auto flex justify-center" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items-center justify-center gap-1.5 w-full py-1 font-medium text-sm transition-colors outline-none ${isActive ? 'text-primary' : 'text-muted-foreground hover:text-white'}`}
|
||||
>
|
||||
<span className="truncate max-w-[110px]">{placeholder}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-500 flex-shrink-0 ${isOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`absolute top-full left-1/2 -translate-x-1/2 mt-2 w-[200px] max-h-[300px] overflow-y-auto bg-slate-900 border border-border/50 rounded-lg shadow-xl z-50 flex flex-col py-1 transition-all duration-500 ease-in-out origin-top ${isOpen ? "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"); setIsOpen(false); }}
|
||||
className={`px-3 py-2 text-sm text-left transition-colors w-full ${value === "All" ? "bg-primary/20 text-white font-medium" : "text-slate-300 hover:bg-white/5 hover:text-white"}`}
|
||||
>
|
||||
All Risks
|
||||
</button>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => { onChange(opt); setIsOpen(false); }}
|
||||
className={`px-3 py-2 text-sm text-left transition-colors w-full ${value === opt ? "bg-primary/20 text-white font-medium" : "text-slate-300 hover:bg-white/5 hover:text-white"}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
secDevices: SecurityDevice[];
|
||||
secDevicesLoading: boolean;
|
||||
}
|
||||
|
||||
export default function DeviceRiskTable({ secDevices, secDevicesLoading }: Props) {
|
||||
const [selectedDevice, setSelectedDevice] = useState<SecurityDevice | null>(null);
|
||||
const [filterRisk, setFilterRisk] = useState<string>("All");
|
||||
|
||||
const secDeviceColumns: Column<SecurityDevice>[] = [
|
||||
{ header: "#", accessor: (_, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
|
||||
{
|
||||
header: "IP Address",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setSelectedDevice(row)}
|
||||
className="font-mono text-sm text-blue-400 hover:text-blue-300 transition-colors flex items-center justify-center gap-1 group mx-auto text-center"
|
||||
>
|
||||
{row.ip_address}
|
||||
</button>
|
||||
),
|
||||
className: "w-[140px] min-w-[140px] max-w-[140px]"
|
||||
},
|
||||
{
|
||||
header: "Device",
|
||||
accessor: (row) => {
|
||||
const title = `${row.device_label || row.ip_address} (${row.device_type || 'Unknown'})`;
|
||||
return (
|
||||
<div title={title} className="flex flex-col items-center cursor-help w-full truncate text-center">
|
||||
<span className="text-sm text-white truncate max-w-full">{row.device_label || row.ip_address}</span>
|
||||
{row.device_type && <span className="text-xs text-slate-400 truncate max-w-full">{row.device_type}</span>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: "w-[180px] min-w-[180px] max-w-[180px]"
|
||||
},
|
||||
{
|
||||
header: "OS / Manufacturer",
|
||||
accessor: (row) => {
|
||||
const title = `${row.os_label || '-'} / ${row.manufacturer || ''}`;
|
||||
return (
|
||||
<div title={title} className="flex flex-col items-center cursor-help w-full truncate text-center">
|
||||
<span className="text-xs text-blue-400 truncate max-w-full">{row.os_label || "-"}</span>
|
||||
<span className="text-xs text-slate-500 truncate max-w-full">{row.manufacturer || ""}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: "w-[180px] min-w-[180px] max-w-[180px]"
|
||||
},
|
||||
{
|
||||
header: "Encrypted %",
|
||||
accessor: (row) => {
|
||||
const pct = row.encrypted_pct ?? 0;
|
||||
const color = pct >= 80 ? "text-green-400" : pct >= 50 ? "text-orange-400" : "text-red-400";
|
||||
const bar = pct >= 80 ? "from-green-500 to-emerald-400" : pct >= 50 ? "from-orange-500 to-yellow-400" : "from-red-500 to-pink-400";
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 w-full text-center">
|
||||
<span className={`text-sm font-semibold ${color}`}>{pct.toFixed(1)}%</span>
|
||||
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden w-24 mx-auto">
|
||||
<div className={`h-full bg-gradient-to-r ${bar} rounded-full`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: "w-[150px] min-w-[150px] max-w-[150px]"
|
||||
},
|
||||
{
|
||||
header: "Enc. / Unenc.",
|
||||
accessor: (row) => (
|
||||
<span className="text-xs font-semibold font-mono flex items-center justify-center gap-1.5">
|
||||
<span className="text-green-400">{fmtBytes(row.encrypted)}</span>
|
||||
<span className="text-slate-600">/</span>
|
||||
<span className="text-red-400">{fmtBytes(row.unencrypted)}</span>
|
||||
</span>
|
||||
),
|
||||
className: "w-[180px] min-w-[180px] max-w-[180px]"
|
||||
},
|
||||
{
|
||||
header: <FilterSelect value={filterRisk} onChange={setFilterRisk} options={["Safe", "Moderate", "Vulnerable"]} placeholder="Risk Audit" />,
|
||||
accessor: (row) => {
|
||||
const config = RISK_CONFIG[row.risk_level] || RISK_CONFIG.Safe;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ring-1 ${config.bg} ${config.color} mx-auto justify-center`}>
|
||||
{config.icon}
|
||||
{row.risk_level}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px]"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
Device Risk Assessment
|
||||
{secDevicesLoading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<span className="ml-auto text-xs text-slate-400 font-normal flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
Based on encryption audit data. Click IP to inspect device.
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{secDevices.length === 0 && !secDevicesLoading ? (
|
||||
<div className="text-center py-10 text-slate-500 text-sm">
|
||||
No encryption audit data available yet. The scheduler will populate this table automatically.
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={secDevices}
|
||||
columns={secDeviceColumns}
|
||||
searchPlaceholder="Search by IP, device type, OS..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
const matchesSearch = (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.device_label || "").toLowerCase().includes(q) ||
|
||||
(row.device_type || "").toLowerCase().includes(q) ||
|
||||
(row.os_label || "").toLowerCase().includes(q) ||
|
||||
(row.risk_level || "").toLowerCase().includes(q)
|
||||
);
|
||||
const matchesRisk = filterRisk === "All" || row.risk_level === filterRisk;
|
||||
return matchesSearch && matchesRisk;
|
||||
}}
|
||||
pageSize={50}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedDevice && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip_address}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// src/components/security-audit/RiskSummaryCards.tsx
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { ShieldX, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
vulnerableCount: number;
|
||||
moderateCount: number;
|
||||
safeCount: number;
|
||||
}
|
||||
|
||||
export default function RiskSummaryCards({ vulnerableCount, moderateCount, safeCount }: Props) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="bg-red-950/20 border-red-500/20 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-red-300">Vulnerable</CardTitle>
|
||||
<ShieldX className="h-4 w-4 text-red-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-red-400">{vulnerableCount}</div>
|
||||
<p className="text-xs text-red-300/60 mt-1">device{vulnerableCount !== 1 ? "s" : ""} at risk</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-orange-950/20 border-orange-500/20 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-orange-300">Moderate</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-orange-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-orange-400">{moderateCount}</div>
|
||||
<p className="text-xs text-orange-300/60 mt-1">device{moderateCount !== 1 ? "s" : ""} moderate risk</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-green-950/20 border-green-500/20 backdrop-blur-sm">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-green-300">Safe (Secure)</CardTitle>
|
||||
<ShieldCheck className="h-4 w-4 text-green-400" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-400">{safeCount}</div>
|
||||
<p className="text-xs text-green-300/60 mt-1">device{safeCount !== 1 ? "s" : ""} secure</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// src/components/security-audit/SecurityDistribution.tsx
|
||||
"use client";
|
||||
|
||||
import { TlsSecurityStat } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
const COLORS = {
|
||||
Recommended: "#3fb950",
|
||||
Secure: "#58a6ff",
|
||||
Weak: "#f0883e",
|
||||
Insecure: "#f85149",
|
||||
};
|
||||
|
||||
interface Props {
|
||||
securityData: TlsSecurityStat[];
|
||||
}
|
||||
|
||||
export default function SecurityDistribution({ securityData }: Props) {
|
||||
return (
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Security Level Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[250px] w-full flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={securityData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="total"
|
||||
nameKey="tls_security"
|
||||
>
|
||||
{securityData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[entry.tls_security as keyof typeof COLORS] || "#8884d8"} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
|
||||
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col justify-center ml-4 space-y-3">
|
||||
{securityData.map((entry, index) => {
|
||||
const TLS_VER: Record<string, string> = {
|
||||
Recommended: "TLS 1.3",
|
||||
Secure: "TLS 1.2",
|
||||
Weak: "TLS 1.0 / 1.1",
|
||||
Insecure: "SSL 2.0 / 3.0",
|
||||
};
|
||||
const dotColor = COLORS[entry.tls_security as keyof typeof COLORS] || "#8884d8";
|
||||
return (
|
||||
<div key={`${entry.tls_security}-${index}`} className="flex items-start gap-2 whitespace-nowrap">
|
||||
<div className="w-3 h-3 rounded-full shrink-0 mt-0.5" style={{ backgroundColor: dotColor }} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-white font-medium">{entry.tls_security}</span>
|
||||
<span className="text-[10px] font-mono" style={{ color: dotColor }}>{TLS_VER[entry.tls_security] || ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// src/components/security-audit/SslSanTable.tsx
|
||||
"use client";
|
||||
|
||||
import { useSslSans, SslSanStat } from "@/lib/api-with-context";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Loader2, Info } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
export default function SslSanTable() {
|
||||
const { data, isLoading } = useSslSans(100);
|
||||
|
||||
const columns: Column<SslSanStat>[] = [
|
||||
{
|
||||
header: "#",
|
||||
accessor: (_, i) => i + 1,
|
||||
className: "w-[50px] min-w-[50px] max-w-[50px]"
|
||||
},
|
||||
{
|
||||
header: "Subject Alternative Name (SAN)",
|
||||
accessor: (row) => (
|
||||
<div
|
||||
title={row.alt_name || '-'}
|
||||
className="font-mono text-xs text-blue-400 hover:text-blue-300 truncate w-full block text-center cursor-help"
|
||||
>
|
||||
{row.alt_name || '-'}
|
||||
</div>
|
||||
),
|
||||
className: "w-[300px] min-w-[300px] max-w-[300px]"
|
||||
},
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>,
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px]"
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>,
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px]"
|
||||
},
|
||||
{
|
||||
header: "Active Flows",
|
||||
accessor: (row) => <span className="text-purple-400 font-mono">{row.flows.toLocaleString()}</span>,
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px]"
|
||||
},
|
||||
{
|
||||
header: "Total Volume",
|
||||
accessor: (row) => <span className="font-semibold text-white">{fmtBytes(row.total)}</span>,
|
||||
className: "w-[130px] min-w-[130px] max-w-[130px]"
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
Subject Alternative Name (SAN) Audit
|
||||
{isLoading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<span className="ml-auto text-xs text-slate-400 font-normal flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
TLS Certificate Domains and Subdomains parsed in real-time.
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{data.length === 0 && !isLoading ? (
|
||||
<div className="text-center py-10 text-slate-500 text-sm">
|
||||
No SSL/TLS certificate SAN data available yet.
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search by hostname / domain..."
|
||||
searchFilter={(row, query) => (row.alt_name || "").toLowerCase().includes(query.toLowerCase())}
|
||||
pageSize={50}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// src/components/security-audit/TlsTables.tsx
|
||||
"use client";
|
||||
|
||||
import { TlsVersionStat, TlsCipherStat } from "@/lib/api";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
versionsData: TlsVersionStat[];
|
||||
ciphersData: TlsCipherStat[];
|
||||
}
|
||||
|
||||
export default function TlsTables({ versionsData, ciphersData }: Props) {
|
||||
const versionColumns: Column<TlsVersionStat>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[40px] min-w-[40px] max-w-[40px]" },
|
||||
{
|
||||
header: "TLS Version",
|
||||
accessor: (row) => (
|
||||
<div title={row.tls_version || '-'} className="font-semibold text-white truncate w-full block text-center cursor-help">
|
||||
{row.tls_version || '-'}
|
||||
</div>
|
||||
),
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px]"
|
||||
},
|
||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>, className: "w-[90px] min-w-[90px] max-w-[90px]" },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>, className: "w-[90px] min-w-[90px] max-w-[90px]" },
|
||||
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span>, className: "w-[110px] min-w-[110px] max-w-[110px]" },
|
||||
];
|
||||
|
||||
const cipherColumns: Column<TlsCipherStat>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[40px] min-w-[40px] max-w-[40px]" },
|
||||
{
|
||||
header: "Cipher Suite",
|
||||
accessor: (row) => (
|
||||
<div title={row.tls_cipher || '-'} className="text-purple-400 truncate w-full block text-center cursor-help">
|
||||
{row.tls_cipher || '-'}
|
||||
</div>
|
||||
),
|
||||
className: "w-[200px] min-w-[200px] max-w-[200px]"
|
||||
},
|
||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>, className: "w-[90px] min-w-[90px] max-w-[90px]" },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>, className: "w-[90px] min-w-[90px] max-w-[90px]" },
|
||||
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span>, className: "w-[110px] min-w-[110px] max-w-[110px]" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* TLS Versions Table */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">TLS Versions Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={versionsData}
|
||||
columns={versionColumns}
|
||||
searchPlaceholder="Search versions..."
|
||||
searchFilter={(row, query) => (row.tls_version || "").toLowerCase().includes(query.toLowerCase())}
|
||||
pageSize={50}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TLS Ciphers Table */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Top TLS Cipher Suites</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={ciphersData}
|
||||
columns={cipherColumns}
|
||||
searchPlaceholder="Search ciphers..."
|
||||
searchFilter={(row, query) => (row.tls_cipher || "").toLowerCase().includes(query.toLowerCase())}
|
||||
pageSize={50}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// src/components/security-audit/TlsVersionsChart.tsx
|
||||
"use client";
|
||||
|
||||
import { TlsVersionStat } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { BarChart, Bar, Cell, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
const VERSION_COLORS: Record<string, string> = {
|
||||
"TLS 1.3": "#3fb950",
|
||||
"TLS 1.3 - Fizz":"#56d364",
|
||||
"TLS 1.2": "#58a6ff",
|
||||
"TLS 1.1": "#ffd700",
|
||||
"TLS 1.0": "#ff8c00",
|
||||
"SSL 3.0": "#f85149",
|
||||
"SSL 2.0": "#ff4444",
|
||||
};
|
||||
|
||||
const VERSION_FALLBACK = ["#58a6ff","#3fb950","#ff8c00","#ffd700","#f85149","#bc8cff","#79c0ff","#ffab70"];
|
||||
|
||||
function ColoredTick({ x, y, payload, index }: any) {
|
||||
const color = VERSION_COLORS[payload?.value] || VERSION_FALLBACK[index % VERSION_FALLBACK.length];
|
||||
return (
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0} y={0} dy={4}
|
||||
textAnchor="end"
|
||||
fill={color}
|
||||
fontSize={10}
|
||||
fontWeight={600}
|
||||
transform="rotate(-35)"
|
||||
>
|
||||
{payload?.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function TlsVersionTooltip({ active, payload }: any) {
|
||||
if (!active || !payload || payload.length === 0) return null;
|
||||
const entry = payload[0];
|
||||
const barColor = entry.fill || VERSION_COLORS[entry.payload?.tls_version] || "#c9d1d9";
|
||||
const version = entry.payload?.tls_version || "Unknown";
|
||||
const value = entry.value ?? 0;
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: "#0d1117",
|
||||
border: `1px solid ${barColor}44`,
|
||||
borderRadius: "8px",
|
||||
padding: "10px 14px",
|
||||
boxShadow: `0 0 12px ${barColor}33`,
|
||||
}}>
|
||||
<p style={{ color: barColor, fontWeight: 700, fontSize: 13, marginBottom: 4 }}>{version}</p>
|
||||
<p style={{ color: "#c9d1d9", fontSize: 12, margin: 0 }}>
|
||||
{version} : <span style={{ color: barColor, fontWeight: 600 }}>{fmtBytes(value)}</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
versionsData: TlsVersionStat[];
|
||||
}
|
||||
|
||||
export default function TlsVersionsChart({ versionsData }: Props) {
|
||||
return (
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">TLS Versions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[290px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={versionsData} margin={{ top: 10, right: 20, left: 20, bottom: 55 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="tls_version"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={0}
|
||||
height={60}
|
||||
tick={<ColoredTick />}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#8b949e"
|
||||
tick={{ fill: "#8b949e", fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
scale="log"
|
||||
domain={[1, 'auto']}
|
||||
allowDataOverflow
|
||||
tickFormatter={(value) => {
|
||||
if (value >= 1073741824) return `${(value / 1073741824).toFixed(0)}GB`;
|
||||
if (value >= 1048576) return `${(value / 1048576).toFixed(0)}MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(0)}KB`;
|
||||
return `${value}B`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip content={<TlsVersionTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||||
<Bar dataKey="total" radius={[4, 4, 0, 0]} minPointSize={6}>
|
||||
{versionsData.map((entry: any, index: number) => {
|
||||
const color = VERSION_COLORS[entry.tls_version] || VERSION_FALLBACK[index % VERSION_FALLBACK.length];
|
||||
return <Cell key={`cell-${index}`} fill={color} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"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={() => {
|
||||
onClear();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="px-2 py-1.5 text-xs bg-red-500/10 text-red-400 border border-red-500/30 rounded-lg hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
interface DropdownFilterSelectProps {
|
||||
value: string;
|
||||
options: string[];
|
||||
onChange: (v: string) => void;
|
||||
placeholder: string;
|
||||
optionFormatter?: (v: string) => string;
|
||||
}
|
||||
|
||||
export function DropdownFilterSelect({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
placeholder,
|
||||
optionFormatter,
|
||||
}: DropdownFilterSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const active = value !== "All";
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", h);
|
||||
return () => document.removeEventListener("mousedown", h);
|
||||
}, []);
|
||||
|
||||
const displayValue = active
|
||||
? optionFormatter
|
||||
? optionFormatter(value)
|
||||
: value
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={`flex items-center justify-between w-full px-3 py-2 text-xs rounded-lg border transition-all text-left outline-none ${
|
||||
active
|
||||
? "border-primary/50 bg-primary/10 text-primary font-medium"
|
||||
: "border-border/50 bg-slate-900 text-slate-300 hover:border-slate-700 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{displayValue}</span>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform duration-500 shrink-0 ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Floating Pop-up with 0.5s transition animations (origin-top) */}
|
||||
<div
|
||||
className={`absolute top-full left-0 right-0 mt-1 max-h-[200px] overflow-y-auto bg-slate-900 border border-border/50 rounded-lg shadow-xl z-50 py-1 transition-all duration-500 ease-in-out origin-top ${
|
||||
open
|
||||
? "opacity-100 scale-y-100 translate-y-0 pointer-events-auto"
|
||||
: "opacity-0 scale-y-90 -translate-y-2 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onChange("All");
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-1.5 text-xs text-left transition-colors ${
|
||||
value === "All"
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{options.map((opt) => {
|
||||
const display = optionFormatter ? optionFormatter(opt) : opt;
|
||||
return (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => {
|
||||
onChange(opt);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-1.5 text-xs text-left transition-colors truncate ${
|
||||
value === opt
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-300 hover:bg-white/5 hover:text-white"
|
||||
}`}
|
||||
title={display}
|
||||
>
|
||||
{display}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { ThreatStat } from "@/lib/api";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
import { DropdownFilterSelect } from "./DropdownFilterSelect";
|
||||
import { DateRangePicker } from "./DateRangePicker";
|
||||
import { sortAppOptions } from "./sortAppOptions";
|
||||
|
||||
interface ThreatFiltersProps {
|
||||
all: ThreatStat[];
|
||||
fType: string;
|
||||
setFType: (v: string) => void;
|
||||
fSev: string;
|
||||
setFSev: (v: string) => void;
|
||||
fSrcIp: string;
|
||||
setFSrcIp: (v: string) => void;
|
||||
fDstIp: string;
|
||||
setFDstIp: (v: string) => void;
|
||||
fMac: string;
|
||||
setFMac: (v: string) => void;
|
||||
fApp: string;
|
||||
setFApp: (v: string) => void;
|
||||
fDomain: string;
|
||||
setFDomain: (v: string) => void;
|
||||
dateFrom: string;
|
||||
setDateFrom: (v: string) => void;
|
||||
dateTo: string;
|
||||
setDateTo: (v: string) => void;
|
||||
activeFilterCount: number;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ThreatFilters({
|
||||
all,
|
||||
fType,
|
||||
setFType,
|
||||
fSev,
|
||||
setFSev,
|
||||
fSrcIp,
|
||||
setFSrcIp,
|
||||
fDstIp,
|
||||
setFDstIp,
|
||||
fMac,
|
||||
setFMac,
|
||||
fApp,
|
||||
setFApp,
|
||||
fDomain,
|
||||
setFDomain,
|
||||
dateFrom,
|
||||
setDateFrom,
|
||||
dateTo,
|
||||
setDateTo,
|
||||
activeFilterCount,
|
||||
onReset,
|
||||
}: ThreatFiltersProps) {
|
||||
// Derive unique options from data
|
||||
const opts = (key: keyof ThreatStat) => {
|
||||
const raw = Array.from(
|
||||
new Set(all.map((r) => String(r[key] || "")).filter(Boolean))
|
||||
);
|
||||
if (key === "app_label") {
|
||||
return sortAppOptions(raw);
|
||||
}
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
// Earliest date for the calendar min
|
||||
const minDate = useMemo(() => {
|
||||
if (!all.length) return "2024-01-01";
|
||||
const dates = all
|
||||
.map((r) => (r.detected_at ? new Date(r.detected_at).getTime() : Infinity))
|
||||
.filter(isFinite);
|
||||
return dates.length
|
||||
? new Date(Math.min(...dates)).toISOString().slice(0, 10)
|
||||
: "2024-01-01";
|
||||
}, [all]);
|
||||
|
||||
return (
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 relative z-30 overflow-visible">
|
||||
<CardHeader className="border-b border-border/50 pb-3 flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||
Filters
|
||||
</CardTitle>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2.5 py-0.5 rounded-full font-semibold">
|
||||
{activeFilterCount} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-xs font-medium text-red-400 hover:text-red-300 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Reset Filters
|
||||
</button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 overflow-visible">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 overflow-visible">
|
||||
{/* Date Range */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Date Range
|
||||
</label>
|
||||
<DateRangePicker
|
||||
minDate={minDate}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onChange={(f, t) => {
|
||||
setDateFrom(f);
|
||||
setDateTo(t);
|
||||
}}
|
||||
onClear={() => {
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Type
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fType}
|
||||
onChange={setFType}
|
||||
options={opts("threat_type")}
|
||||
placeholder="All Types"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Severity
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fSev}
|
||||
onChange={setFSev}
|
||||
options={opts("severity")}
|
||||
placeholder="All Severities"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source IP */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Source IP
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fSrcIp}
|
||||
onChange={setFSrcIp}
|
||||
options={opts("ip_address")}
|
||||
placeholder="All Source IPs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dest IP */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Dest IP
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fDstIp}
|
||||
onChange={setFDstIp}
|
||||
options={opts("dst_ip")}
|
||||
placeholder="All Dest IPs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MAC */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
MAC Address
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fMac}
|
||||
onChange={setFMac}
|
||||
options={opts("mac_address")}
|
||||
placeholder="All MACs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* App */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
App
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fApp}
|
||||
onChange={setFApp}
|
||||
options={opts("app_label")}
|
||||
placeholder="All Apps"
|
||||
optionFormatter={formatAppLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Domain */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Domain
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fDomain}
|
||||
onChange={setFDomain}
|
||||
options={opts("domain")}
|
||||
placeholder="All Domains"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, ShieldAlert, CheckCircle2, Server, HelpCircle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { ThreatStat } from "@/lib/api";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
import { getMitigationSteps } from "./getMitigationSteps";
|
||||
|
||||
interface ThreatRecommendationsDrawerProps {
|
||||
threat: ThreatStat | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ThreatRecommendationsDrawer({
|
||||
threat,
|
||||
onClose,
|
||||
}: ThreatRecommendationsDrawerProps) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [activeThreat, setActiveThreat] = useState<ThreatStat | null>(null);
|
||||
|
||||
// Sync threat to activeThreat and handle mounting/resetting close state
|
||||
useEffect(() => {
|
||||
if (threat) {
|
||||
setActiveThreat(threat);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [threat]);
|
||||
|
||||
if (!threat && !activeThreat) return null;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setActiveThreat(null);
|
||||
setIsClosing(false);
|
||||
}, 480); // Match 500ms keyframe animation duration
|
||||
};
|
||||
|
||||
const displayThreat = threat || activeThreat;
|
||||
if (!displayThreat) return null;
|
||||
|
||||
const sev = (displayThreat.severity || "").toLowerCase();
|
||||
let badgeVariant: "red" | "orange" | "yellow" | "blue" | "green" = "blue";
|
||||
if (sev.includes("high") || sev.includes("critical")) badgeVariant = "red";
|
||||
else if (sev.includes("med")) badgeVariant = "orange";
|
||||
else if (sev.includes("low")) badgeVariant = "yellow";
|
||||
|
||||
const mitigation = getMitigationSteps(displayThreat.threat_type || "");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 cursor-default"
|
||||
onClick={handleClose}
|
||||
>
|
||||
{/* Backdrop overlay (fade in/out over 0.5s via keyframes) */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/60 backdrop-blur-sm ${
|
||||
isClosing ? "animate-overlay-out" : "animate-overlay-in"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Centered Modal Content Box (fades and scales over 0.5s via keyframes) */}
|
||||
<div
|
||||
className={`relative bg-slate-950/85 border border-slate-800/60 rounded-3xl w-full max-w-2xl h-[75vh] flex flex-col shadow-2xl shadow-blue-900/20 overflow-hidden backdrop-blur-xl ${
|
||||
isClosing ? "animate-modal-out" : "animate-modal-in"
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-white/5 bg-slate-950/40 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ShieldAlert className="w-5 h-5 text-red-500 shrink-0" />
|
||||
<h2 className="text-base font-bold text-white tracking-wide">
|
||||
Mitigation Guide
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-slate-400 hover:text-white rounded-lg p-1.5 hover:bg-white/5 transition-all outline-none cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6 custom-scrollbar">
|
||||
{/* Threat Metadata Card */}
|
||||
<div className="bg-slate-950/30 border border-white/5 rounded-xl p-4.5 space-y-3.5">
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block mb-1">
|
||||
Threat Type
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-bold text-white leading-tight">
|
||||
{displayThreat.threat_type || "Unknown Threat"}
|
||||
</span>
|
||||
<Badge variant={badgeVariant} className="text-[10px] uppercase font-bold shrink-0">
|
||||
{displayThreat.severity}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block mb-1">
|
||||
Description
|
||||
</span>
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
{displayThreat.description || "No detailed description available."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical details block */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Server className="w-3.5 h-3.5" />
|
||||
Technical Details
|
||||
</h3>
|
||||
<div className="bg-slate-950/20 border border-white/5 rounded-xl divide-y divide-white/5">
|
||||
<div className="px-4 py-2.5 flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400">Source IP</span>
|
||||
<span className="font-mono font-medium text-white">{displayThreat.ip_address || "-"}</span>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400">Source MAC</span>
|
||||
<span className="font-mono font-medium text-white">{displayThreat.mac_address || "-"}</span>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400">Destination IP</span>
|
||||
<span className="font-mono font-medium text-white">{displayThreat.dst_ip || "-"}</span>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400">Application</span>
|
||||
<span className="font-medium text-white">{formatAppLabel(displayThreat.app_label)}</span>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400">Domain</span>
|
||||
<span className="font-mono font-medium text-white truncate max-w-[200px]" title={displayThreat.domain || ""}>
|
||||
{displayThreat.domain || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-4 py-2.5 flex items-center justify-between text-xs">
|
||||
<span className="text-slate-400">Detected At</span>
|
||||
<span className="text-slate-300">
|
||||
{displayThreat.detected_at ? new Date(displayThreat.detected_at).toLocaleString() : "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations checklist */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-primary" />
|
||||
Recommendations & Actions
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 leading-relaxed italic mb-1">
|
||||
{mitigation.intro}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{mitigation.steps.map((step, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-3 bg-slate-950/20 hover:bg-slate-950/45 transition-colors p-3.5 border border-white/5 rounded-xl"
|
||||
>
|
||||
<div className="p-0.5 rounded bg-emerald-500/10 text-emerald-400 shrink-0">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<h4 className="text-xs font-bold text-white flex items-center gap-2">
|
||||
{step.title}
|
||||
</h4>
|
||||
<p className="text-[11px] text-slate-400 leading-normal">
|
||||
{step.desc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Action */}
|
||||
<div className="px-6 py-4.5 border-t border-white/5 bg-slate-950/40 flex justify-end">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-xs font-semibold rounded-lg bg-slate-800 text-slate-200 hover:bg-slate-700 hover:text-white transition-all cursor-pointer border border-white/5"
|
||||
>
|
||||
Close Guide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
interface MitigationStep {
|
||||
title: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
export function getMitigationSteps(type: string): { intro: string; steps: MitigationStep[] } {
|
||||
const defaultIntro = "Follow these best practice security steps to mitigate and secure your network.";
|
||||
|
||||
switch (type) {
|
||||
case "Insecure Plaintext Password":
|
||||
return {
|
||||
intro: "Plaintext credentials were detected over an unencrypted channel (HTTP). This exposes passwords to eavesdropping and interception.",
|
||||
steps: [
|
||||
{
|
||||
title: "Force HTTPS Redirection",
|
||||
desc: "Configure the destination server to automatically redirect all port 80 (HTTP) traffic to port 443 (HTTPS).",
|
||||
},
|
||||
{
|
||||
title: "Enable Strict-Transport-Security (HSTS)",
|
||||
desc: "Add the HSTS response header to force browsers to always connect via HTTPS for this domain.",
|
||||
},
|
||||
{
|
||||
title: "Rotate Exposed Credentials",
|
||||
desc: "Immediately rotate and change passwords that were transmitted over this unencrypted connection.",
|
||||
},
|
||||
{
|
||||
title: "Audit Client Applications",
|
||||
desc: "Update the client application configurations to enforce SSL/TLS encrypted APIs/endpoints.",
|
||||
}
|
||||
]
|
||||
};
|
||||
case "Cryptomining Connection":
|
||||
return {
|
||||
intro: "An active outbound connection to a known cryptocurrency mining pool was detected. This typically indicates a malware infection or unauthorized resource use.",
|
||||
steps: [
|
||||
{
|
||||
title: "Isolate the Source Device",
|
||||
desc: "Quarantine the infected machine from other local network segments to prevent lateral movement of malware.",
|
||||
},
|
||||
{
|
||||
title: "Block Mining Pool Endpoints",
|
||||
desc: "Add destination IPs/domains (e.g. stratum server addresses) to the firewall blocklist.",
|
||||
},
|
||||
{
|
||||
title: "Run Deep Malware Scanner",
|
||||
desc: "Execute a thorough antivirus scan on the device to locate and remove mining trojans (cryptojackers).",
|
||||
},
|
||||
{
|
||||
title: "Check System Resource Usage",
|
||||
desc: "Review Task Manager / top processes on the source device for suspicious high CPU/GPU spikes.",
|
||||
}
|
||||
]
|
||||
};
|
||||
case "Tor Exit Node Traffic":
|
||||
return {
|
||||
intro: "Active communication with a Tor exit node was detected. Tor is often used to bypass firewalls, mask identities, or establish command-and-control channels.",
|
||||
steps: [
|
||||
{
|
||||
title: "Audit Authorized Tor Use",
|
||||
desc: "Verify whether Tor client software is authorized for use on this specific device under company policy.",
|
||||
},
|
||||
{
|
||||
title: "Block Tor Gateways",
|
||||
desc: "Implement firewall rules to drop outbound traffic to publicly known Tor exit node IP addresses.",
|
||||
},
|
||||
{
|
||||
title: "Inspect DNS Logs",
|
||||
desc: "Review DNS query histories on the source device to check for onion routing lookups or malicious domains.",
|
||||
},
|
||||
{
|
||||
title: "Remove Tor Browsers",
|
||||
desc: "Uninstall unauthorized Tor client software or browsers from the user endpoint.",
|
||||
}
|
||||
]
|
||||
};
|
||||
case "Malicious IP Reputation":
|
||||
return {
|
||||
intro: "A connection was established with an IP address associated with known malicious activity (spam, botnets, malware distribution, or command-and-control).",
|
||||
steps: [
|
||||
{
|
||||
title: "Deploy Firewall Drops",
|
||||
desc: "Apply strict packet filtering rules to drop all incoming and outgoing connections to the malicious IP address.",
|
||||
},
|
||||
{
|
||||
title: "Scan Endpoint for Trojans",
|
||||
desc: "The source device might be a participant in a botnet. Run security scans to check for command-and-control agents.",
|
||||
},
|
||||
{
|
||||
title: "Check Payload Contents",
|
||||
desc: "Review DPI network flow payload logs to determine if credentials, sensitive files, or malware were transferred.",
|
||||
},
|
||||
{
|
||||
title: "Enable Automatic IP Feeds",
|
||||
desc: "Ensure your gateway's intrusion prevention system (IPS) automatically syncs daily threat intelligence lists.",
|
||||
}
|
||||
]
|
||||
};
|
||||
case "Expired SSL/TLS Certificate":
|
||||
return {
|
||||
intro: "An SSL connection was initiated with a remote server using an expired security certificate. This compromises connection trust and opens users to MITM attacks.",
|
||||
steps: [
|
||||
{
|
||||
title: "Renew SSL Certificate",
|
||||
desc: "If the destination is your server, renew the certificate immediately using a public CA (e.g. Let's Encrypt, Sectigo).",
|
||||
},
|
||||
{
|
||||
title: "Enable Cert Auto-Renewal",
|
||||
desc: "Configure automated certificate renewal cron scripts (like certbot / ACME client) to prevent future expirations.",
|
||||
},
|
||||
{
|
||||
title: "Warn Client Browser Users",
|
||||
desc: "Do not ignore browser certificate warnings. Advise users against bypassing safety alerts on the target domain.",
|
||||
},
|
||||
{
|
||||
title: "Check Server Date/Time Settings",
|
||||
desc: "Ensure the target web server's system clock is correctly synchronized via NTP, which affects SSL validation.",
|
||||
}
|
||||
]
|
||||
};
|
||||
case "Unauthorized Port Scan":
|
||||
return {
|
||||
intro: "A device inside or outside the network was observed performing a rapid volume of connection attempts to closed ports, indicating reconnaissance.",
|
||||
steps: [
|
||||
{
|
||||
title: "Check Device Activity",
|
||||
desc: "Identify whether the source IP belongs to an IT administrator running network discovery tools or a compromised machine.",
|
||||
},
|
||||
{
|
||||
title: "Configure Connection Rate-Limits",
|
||||
desc: "Setup rate-limiting policies on the firewall to drop IPs executing excessive connection handshakes per second.",
|
||||
},
|
||||
{
|
||||
title: "Enable IPS Block Rules",
|
||||
desc: "Verify that the Intrusion Prevention System (IPS) is active and configured to automatically block scans.",
|
||||
},
|
||||
{
|
||||
title: "Harden Exposed Ports",
|
||||
desc: "Ensure that sensitive services (SSH, RDP, SMB) are disabled or protected behind a VPN rather than exposed to scans.",
|
||||
}
|
||||
]
|
||||
};
|
||||
default:
|
||||
return {
|
||||
intro: defaultIntro,
|
||||
steps: [
|
||||
{
|
||||
title: "Investigate Device Logs",
|
||||
desc: "Examine system event logs and deep packet flows on the source device to check for abnormal software execution.",
|
||||
},
|
||||
{
|
||||
title: "Block Target Connection",
|
||||
desc: "Add destination ports and target IP addresses to firewall filters until the device behavior is clarified.",
|
||||
},
|
||||
{
|
||||
title: "Quarantine and Scan",
|
||||
desc: "Isolate the client endpoint from the main VLAN and run security scanners to eliminate malware threats.",
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export function sortAppOptions(options: string[]): string[] {
|
||||
const KNOWN_PORTS = new Set([
|
||||
"1433", "1434", "1521", "1812", "1813", "1900", "3306", "3389",
|
||||
"5060", "5061", "5353", "5432", "5900", "6379", "8000", "8080",
|
||||
"8443", "9200", "27017"
|
||||
]);
|
||||
|
||||
const getPortInfo = (opt: string) => {
|
||||
const m = opt.trim().match(/^(?:Port\s+)?(\d+)$/i);
|
||||
if (!m) {
|
||||
return { isPort: false, portNum: 0, isStandard: true, name: opt };
|
||||
}
|
||||
const portNum = parseInt(m[1], 10);
|
||||
const isStandard = portNum < 1024 || KNOWN_PORTS.has(String(portNum));
|
||||
return { isPort: true, portNum, isStandard, name: opt };
|
||||
};
|
||||
|
||||
return [...options].sort((a, b) => {
|
||||
const infoA = getPortInfo(a);
|
||||
const infoB = getPortInfo(b);
|
||||
|
||||
if (infoA.isStandard && !infoB.isStandard) return -1;
|
||||
if (!infoA.isStandard && infoB.isStandard) return 1;
|
||||
|
||||
if (infoA.isStandard && infoB.isStandard) {
|
||||
if (!infoA.isPort && infoB.isPort) return -1;
|
||||
if (infoA.isPort && !infoB.isPort) return 1;
|
||||
if (!infoA.isPort && !infoB.isPort) {
|
||||
return infoA.name.localeCompare(infoB.name);
|
||||
}
|
||||
return infoA.portNum - infoB.portNum;
|
||||
}
|
||||
|
||||
return infoA.portNum - infoB.portNum;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
firstSeen?: string;
|
||||
lastSeen?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function formatActiveDuration(firstSeen?: string, lastSeen?: string) {
|
||||
if (!firstSeen || !lastSeen) return null;
|
||||
const start = new Date(firstSeen).getTime();
|
||||
const end = new Date(lastSeen).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
const dateObj = new Date(lastSeen);
|
||||
const fullTimestamp = dateObj.toLocaleString("en-US", {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
|
||||
let diffSec = Math.floor((end - start) / 1000);
|
||||
if (diffSec < 0) diffSec = 0;
|
||||
|
||||
const h = Math.floor(diffSec / 3600);
|
||||
const m = Math.floor((diffSec % 3600) / 60);
|
||||
const s = diffSec % 60;
|
||||
|
||||
let durationText = "Just now";
|
||||
if (h > 0) durationText = `${h}h ${m}m ${s}s`;
|
||||
else if (m > 0) durationText = `${m}m ${s}s`;
|
||||
else if (s > 0) durationText = `${s}s`;
|
||||
|
||||
const timeSinceLastSeen = Math.floor((now - end) / 1000);
|
||||
const isActive = timeSinceLastSeen <= 300;
|
||||
|
||||
let lastSeenText = "";
|
||||
if (!isActive) {
|
||||
const agoH = Math.floor(timeSinceLastSeen / 3600);
|
||||
const agoM = Math.floor((timeSinceLastSeen % 3600) / 60);
|
||||
if (agoH > 24) {
|
||||
const agoD = Math.floor(agoH / 24);
|
||||
lastSeenText = `${agoD}d ago`;
|
||||
} else if (agoH > 0) {
|
||||
lastSeenText = `${agoH}h ago`;
|
||||
} else if (agoM > 0) {
|
||||
lastSeenText = `${agoM}m ago`;
|
||||
} else {
|
||||
lastSeenText = "a few seconds ago";
|
||||
}
|
||||
}
|
||||
|
||||
return { isActive, durationText, fullTimestamp, lastSeenText };
|
||||
}
|
||||
|
||||
export default function ActiveDurationDisplay({ firstSeen, lastSeen, className = "" }: Props) {
|
||||
if (!firstSeen || !lastSeen) return null;
|
||||
const dur = formatActiveDuration(firstSeen, lastSeen);
|
||||
if (!dur) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1.5 bg-secondary/30 dark:bg-black/20 p-2.5 rounded-lg border border-border text-xs ${className}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className={`flex items-center gap-1.5 ${dur.isActive ? 'text-blue-600 dark:text-blue-400' : 'text-muted-foreground'}`}>
|
||||
<Clock className="w-3.5 h-3.5" /> {dur.isActive ? "Active" : "Last seen"}
|
||||
</span>
|
||||
<span className="text-card-foreground font-medium">
|
||||
{dur.isActive ? dur.durationText : dur.lastSeenText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground/60 text-right">
|
||||
{dur.fullTimestamp}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +1,42 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchAgentDetails, AgentDetails } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { fmtBytes, getAppFavicon } from "@/lib/utils";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { AppDetailModal } from "@/components/ui/AppDetailModal";
|
||||
import {
|
||||
Loader2, X, Server, Monitor, Activity, Globe, Shield,
|
||||
Bell, Wifi, AlertTriangle, Lock, Unlock, ChevronRight,
|
||||
Eye, Router
|
||||
Bell, Wifi, ChevronRight, LineChart
|
||||
} from "lucide-react";
|
||||
|
||||
import AgentDevicesTab from "./AgentDevicesTab";
|
||||
import AgentFlowsTab from "./AgentFlowsTab";
|
||||
import AgentSecurityTab from "./AgentSecurityTab";
|
||||
import AgentEventsTab from "./AgentEventsTab";
|
||||
import AgentMacTab from "./AgentMacTab";
|
||||
import AgentTelemetryTab from "./AgentTelemetryTab";
|
||||
|
||||
interface Props {
|
||||
agentUuid: string;
|
||||
agentLabel: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "devices" | "flows" | "apps" | "security" | "events" | "mac";
|
||||
|
||||
// ── Severity colour helper ─────────────────────────────────────────────────
|
||||
function severityStyle(sev: string | null) {
|
||||
switch ((sev || "").toLowerCase()) {
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "high": return { badge: "bg-orange-500/15 text-orange-400 ring-orange-500/30", dot: "bg-orange-400" };
|
||||
case "warning": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "info": return { badge: "bg-blue-500/15 text-blue-400 ring-blue-500/30", dot: "bg-blue-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
function riskStyle(level: string | null) {
|
||||
switch ((level || "").toLowerCase()) {
|
||||
case "vulnerable":
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "moderate":
|
||||
case "medium": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "safe":
|
||||
case "low": return { badge: "bg-green-500/15 text-green-400 ring-green-500/30", dot: "bg-green-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared table wrapper ────────────────────────────────────────────────────
|
||||
function TableWrap({ children, head }: { children: React.ReactNode; head: string[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border/50">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{head.map((h) => (
|
||||
<th key={h} className="px-3 py-2 text-left font-medium text-muted-foreground whitespace-nowrap">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
type Tab = "devices" | "flows" | "apps" | "security" | "events" | "mac" | "telemetry";
|
||||
|
||||
function EmptyState({ msg }: { msg: string }) {
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">{msg}</p>;
|
||||
}
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────────────
|
||||
export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
const [data, setData] = useState<AgentDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("devices");
|
||||
const [selectedDevice, setSelectedDevice] = useState<{ ip: string; label: string | null; mac: string | null } | null>(null);
|
||||
const [selectedDevice, setSelectedDevice] = useState<{ ip: string; label: string | null; mac: string | null; agent_uuid?: string } | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [selectedAppLabel, setSelectedAppLabel] = useState<string | null>(null);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
@@ -89,7 +54,12 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [agentUuid]);
|
||||
|
||||
// Count security alerts
|
||||
// Reset tab scroll/state when switching tabs
|
||||
useEffect(() => {
|
||||
const el = document.querySelector(".custom-scrollbar");
|
||||
if (el) el.scrollTop = 0;
|
||||
}, [activeTab]);
|
||||
|
||||
const secCount = data
|
||||
? (data.security?.insecure_protocols?.length ?? 0) +
|
||||
(data.security?.unencrypted_passwords?.length ?? 0) +
|
||||
@@ -98,12 +68,13 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
: 0;
|
||||
|
||||
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number }[] = [
|
||||
{ key: "devices", label: "Devices", icon: <Monitor className="w-3.5 h-3.5" />, count: data?.devices.length },
|
||||
{ key: "flows", label: "Flows", icon: <Activity className="w-3.5 h-3.5" />, count: data?.flows.length },
|
||||
{ key: "apps", label: "Top Apps", icon: <Globe className="w-3.5 h-3.5" />, count: data?.top_apps.length },
|
||||
{ key: "security", label: "Security", icon: <Shield className="w-3.5 h-3.5" />, count: secCount || undefined },
|
||||
{ key: "events", label: "Events", icon: <Bell className="w-3.5 h-3.5" />, count: data?.events.length },
|
||||
{ key: "mac", label: "MAC Bandwidth", icon: <Wifi className="w-3.5 h-3.5" />, count: data?.mac_bandwidth.length },
|
||||
{ key: "devices", label: "Devices", icon: <Monitor className="w-3.5 h-3.5" />, count: data?.devices?.length },
|
||||
{ key: "flows", label: "Flows", icon: <Activity className="w-3.5 h-3.5" />, count: data?.flows?.length },
|
||||
{ key: "apps", label: "Top Apps", icon: <Globe className="w-3.5 h-3.5" />, count: data?.top_apps?.length },
|
||||
{ key: "security", label: "Security", icon: <Shield className="w-3.5 h-3.5" />, count: secCount || undefined },
|
||||
{ key: "events", label: "Events", icon: <Bell className="w-3.5 h-3.5" />, count: data?.events?.length },
|
||||
{ key: "mac", label: "MAC Bandwidth", icon: <Wifi className="w-3.5 h-3.5" />, count: data?.mac_bandwidth?.length },
|
||||
{ key: "telemetry", label: "Telemetry", icon: <LineChart className="w-3.5 h-3.5" /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -114,7 +85,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-blue-900/30 to-purple-900/20 rounded-t-2xl flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg ring-1 ring-blue-500/30">
|
||||
@@ -130,18 +101,17 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
|
||||
<p className="text-muted-foreground text-sm">Loading agent data…</p>
|
||||
<p className="text-muted-foreground text-sm">Loading agent data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* ── KPI row ── */}
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b border-border/50 flex-shrink-0">
|
||||
{[
|
||||
{ label: "Devices", value: data.summary?.total_devices || data.devices.length, color: "text-card-foreground" },
|
||||
@@ -156,15 +126,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Security alert strip ── */}
|
||||
{secCount > 0 && (
|
||||
<div className="mx-6 mt-3 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-xs text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
<span><strong>{secCount}</strong> security alerts detected on this agent — check the Security tab</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-6 py-3 border-b border-border/50 flex-shrink-0 flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
@@ -187,56 +149,42 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tab content ── */}
|
||||
{/* Tab content */}
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
|
||||
{/* DEVICES */}
|
||||
{activeTab === "devices" && (
|
||||
data.devices.length === 0 ? <EmptyState msg="No devices found for this agent." /> : (
|
||||
<div className="space-y-2">
|
||||
{data.devices.map((d, i) => {
|
||||
const rc = riskStyle(d.risk_level);
|
||||
<AgentDevicesTab
|
||||
devices={data.devices}
|
||||
onSelectDevice={(d) => setSelectedDevice({ ip: d.ip, label: d.label, mac: d.mac, agent_uuid: agentUuid })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "flows" && (
|
||||
<AgentFlowsTab flows={data.flows} />
|
||||
)}
|
||||
|
||||
{activeTab === "apps" && (
|
||||
data.top_apps.length === 0 ? <EmptyState msg="No applications accessed by this agent." /> : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{data.top_apps.map((app) => {
|
||||
const appTotal = app.download + app.upload;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setSelectedDevice({ ip: d.ip_address || "", label: d.device_label, mac: d.mac_address })}
|
||||
className="w-full text-left p-3 bg-white/[0.03] hover:bg-white/[0.07] hover:border-cyan-500/30 border border-border/50 rounded-xl transition-all group cursor-pointer"
|
||||
key={app.app_id}
|
||||
onClick={() => setSelectedAppLabel(app.app_label)}
|
||||
className="flex items-center gap-3 p-3 bg-white/[0.03] hover:bg-white/[0.07] border border-border/50 rounded-xl transition-all group w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-slate-800/60 group-hover:bg-cyan-500/10 rounded-lg transition-colors">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground group-hover:text-cyan-400 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors">{d.device_label || d.ip_address || "Unknown"}</p>
|
||||
{d.risk_level && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 ${rc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
|
||||
{d.risk_level}
|
||||
</span>
|
||||
)}
|
||||
{d.has_insecure && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 bg-orange-500/10 ring-orange-500/30 text-orange-400">
|
||||
âš Insecure
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
<p className="text-xs text-muted-foreground font-mono">{d.ip_address}</p>
|
||||
{d.mac_address && <p className="text-xs text-muted-foreground font-mono">{d.mac_address}</p>}
|
||||
{d.os_label && <p className="text-xs text-blue-400">{d.os_label}</p>}
|
||||
{d.manufacturer && <p className="text-xs text-muted-foreground">{d.manufacturer}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 space-y-0.5">
|
||||
<p className="text-xs text-cyan-400">↓ {fmtBytes(d.download ?? 0)}</p>
|
||||
<p className="text-xs text-green-400">↑ {fmtBytes(d.upload ?? 0)}</p>
|
||||
{d.encrypted_pct !== null && (
|
||||
<p className="text-xs text-muted-foreground">🔒 {d.encrypted_pct}%</p>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 ml-auto transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors">{app.app_label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{app.category || "Web"}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-card-foreground font-semibold">{fmtBytes(appTotal)}</p>
|
||||
<span className="text-[10px] text-muted-foreground flex gap-2">
|
||||
<span className="text-cyan-400">DL: {fmtBytes(app.download)}</span>
|
||||
<span className="text-green-400">UL: {fmtBytes(app.upload)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 transition-colors" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -244,361 +192,21 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
)
|
||||
)}
|
||||
|
||||
{/* FLOWS */}
|
||||
{activeTab === "flows" && (
|
||||
data.flows.length === 0 ? <EmptyState msg="No flows found for this agent." /> : (
|
||||
<TableWrap head={["Source IP", "Destination", "App / Domain", "Proto", "↓", "↑", "Last Seen"]}>
|
||||
{data.flows.map((f, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400 whitespace-nowrap">{f.src_ip || "—"}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-slate-300">
|
||||
{f.dst_ip || "—"}{f.dst_port ? <span className="text-muted-foreground">:{f.dst_port}</span> : ""}
|
||||
</span>
|
||||
{f.dst_ip && <IpDetails ip={f.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap max-w-[200px]">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-semibold text-purple-300 text-xs truncate" title={f.app_label || "—"}>
|
||||
{f.app_label || "—"}
|
||||
</span>
|
||||
{f.domain && (
|
||||
<span className="text-[10px] text-muted-foreground italic truncate" title={f.domain}>
|
||||
{f.domain}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{f.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* TOP APPS */}
|
||||
{activeTab === "apps" && (
|
||||
data.top_apps.length === 0 ? <EmptyState msg="No application data for this agent." /> : (
|
||||
<div className="space-y-2">
|
||||
{data.top_apps.map((a, i) => {
|
||||
const total = (a.download ?? 0) + (a.upload ?? 0);
|
||||
const maxTotal = Math.max(...data.top_apps.map(x => (x.download ?? 0) + (x.upload ?? 0)), 1);
|
||||
const pct = (total / maxTotal) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{a.favicon
|
||||
? <img src={a.favicon} alt={a.app_label} className="w-5 h-5 rounded bg-white p-0.5 flex-shrink-0" />
|
||||
: <div className="w-5 h-5 rounded bg-slate-700 flex-shrink-0" />
|
||||
}
|
||||
<span className="font-medium text-card-foreground text-sm flex-1">{a.app_label}</span>
|
||||
{a.category && <span className="text-xs text-muted-foreground bg-secondary px-2 py-0.5 rounded">{a.category}</span>}
|
||||
<span className="text-xs font-mono text-card-foreground">{fmtBytes(total)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground mb-2">
|
||||
<span className="text-cyan-400">↓ {fmtBytes(a.download ?? 0)}</span>
|
||||
<span className="text-green-400">↑ {fmtBytes(a.upload ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* SECURITY */}
|
||||
{activeTab === "security" && (
|
||||
<div className="space-y-6">
|
||||
{/* Encryption Audit */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Lock className="w-4 h-4 text-blue-400" /> Encryption Audit
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.encryption_audit.length ?? 0} devices</span>
|
||||
</h4>
|
||||
{(data.security?.encryption_audit.length ?? 0) === 0 ? <EmptyState msg="No encryption audit data for this agent." /> : (
|
||||
<TableWrap head={["IP Address", "Device", "Encrypted %", "Risk", "Enc Bytes", "Unenc Bytes"]}>
|
||||
{data.security!.encryption_audit.map((r, i) => {
|
||||
const rc = riskStyle(r.risk_level);
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.device_label || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-white/10 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${r.encrypted_pct ?? 0}%` }} />
|
||||
</div>
|
||||
<span className="text-card-foreground">{r.encrypted_pct ?? 0}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${rc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
|
||||
{r.risk_level || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-cyan-400">{fmtBytes(r.encrypted ?? 0)}</td>
|
||||
<td className="px-3 py-2 text-xs text-orange-400">{fmtBytes(r.unencrypted ?? 0)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Insecure Protocols */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Unlock className="w-4 h-4 text-orange-400" /> Insecure Protocols
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.insecure_protocols.length ?? 0} records</span>
|
||||
</h4>
|
||||
{(data.security?.insecure_protocols.length ?? 0) === 0 ? <EmptyState msg="No insecure protocols detected for this agent." /> : (
|
||||
<TableWrap head={["IP", "Protocol", "Risk", "App", "Dst IP:Port", "↓", "↑"]}>
|
||||
{data.security!.insecure_protocols.map((r, i) => {
|
||||
const sc = severityStyle(r.risk);
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-200">{r.protocol || "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{r.risk || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-purple-300 truncate max-w-[100px]">{r.app_label || "—"}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</span>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-cyan-400">{fmtBytes(r.download)}</td>
|
||||
<td className="px-3 py-2 text-right text-xs text-green-400">{fmtBytes(r.upload)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Unencrypted Passwords */}
|
||||
{(data.security?.unencrypted_passwords.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-red-400 mb-3">
|
||||
<AlertTriangle className="w-4 h-4" /> Unencrypted Passwords Detected!
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security!.unencrypted_passwords.length} records</span>
|
||||
</h4>
|
||||
<TableWrap head={["IP", "Dst IP:Port", "Protocol", "Username", "Severity"]}>
|
||||
{data.security!.unencrypted_passwords.map((r, i) => {
|
||||
const sc = severityStyle(r.severity);
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</span>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300">{r.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-yellow-300 font-mono">{r.username || "—"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{r.severity || "—"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* IP Reputation */}
|
||||
{(data.security?.ip_reputation.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Eye className="w-4 h-4 text-purple-400" /> IP Reputation
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security!.ip_reputation.length} records</span>
|
||||
</h4>
|
||||
<TableWrap head={["Remote IP", "Local IP", "Reputation", "Score", "Country", "Blacklisted"]}>
|
||||
{data.security!.ip_reputation.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 font-mono text-xs text-red-400">{r.ip_address || "—"}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.local_ip || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300">{r.reputation || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-yellow-400">{r.score ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.country || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{r.blacklisted
|
||||
? <span className="text-red-400 font-semibold">â›” Yes</span>
|
||||
: <span className="text-green-400">✓ No</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Tor & VPN side-by-side */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Tor */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Router className="w-4 h-4 text-red-400" /> Tor Detections
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.tor_detections.length ?? 0}</span>
|
||||
</h4>
|
||||
{(data.security?.tor_detections.length ?? 0) === 0
|
||||
? <EmptyState msg="No Tor usage detected." />
|
||||
: <div className="space-y-2">{data.security!.tor_detections.map((r, i) => (
|
||||
<div key={i} className="p-2 bg-red-500/5 border border-red-500/20 rounded-lg text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
|
||||
<span className="text-muted-foreground">{r.country || "—"}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5">Exit: {r.exit_node || "—"}</div>
|
||||
</div>
|
||||
))}</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
{/* VPN */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Shield className="w-4 h-4 text-blue-400" /> VPN Detections
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.security?.vpn_detections.length ?? 0}</span>
|
||||
</h4>
|
||||
{(data.security?.vpn_detections.length ?? 0) === 0
|
||||
? <EmptyState msg="No VPN usage detected." />
|
||||
: <div className="space-y-2">{data.security!.vpn_detections.map((r, i) => (
|
||||
<div key={i} className="p-2 bg-blue-500/5 border border-blue-500/20 rounded-lg text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
|
||||
<span className="text-purple-400">{r.vpn_type || "—"}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-0.5 flex flex-col gap-0.5">
|
||||
<span>Remote: {r.remote_ip || "—"} • {r.country || "—"}</span>
|
||||
{r.remote_ip && <IpDetails ip={r.remote_ip} />}
|
||||
</div>
|
||||
{r.confidence && <div className="text-muted-foreground">Confidence: {(r.confidence * 100).toFixed(0)}%</div>}
|
||||
</div>
|
||||
))}</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Server Discovery */}
|
||||
{(data.server_discovery?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Server className="w-4 h-4 text-cyan-400" /> Servers Discovered
|
||||
<span className="ml-auto text-xs text-muted-foreground">{data.server_discovery!.length} records</span>
|
||||
</h4>
|
||||
<TableWrap head={["IP", "Hostname", "Server Type", "Port", "Protocol", "OS"]}>
|
||||
{data.server_discovery!.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.03]">
|
||||
<td className="px-3 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{r.ip_address || "—"}</span>
|
||||
{r.ip_address && <IpDetails ip={r.ip_address} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.hostname || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-cyan-400">{r.server_type || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.port ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-purple-300">{r.protocol || "—"}</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{r.os_label || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<AgentSecurityTab data={data} />
|
||||
)}
|
||||
|
||||
{/* EVENTS */}
|
||||
{activeTab === "events" && (
|
||||
data.events.length === 0 ? <EmptyState msg="No events found for this agent." /> : (
|
||||
<div className="space-y-2">
|
||||
{data.events.map((ev, i) => {
|
||||
const sc = severityStyle(ev.severity);
|
||||
return (
|
||||
<div key={i} className={`p-3 border rounded-xl text-xs transition-colors ${
|
||||
(ev.severity || "").toLowerCase() === "critical" ? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10" :
|
||||
(ev.severity || "").toLowerCase() === "high" ? "bg-orange-500/5 border-orange-500/20 hover:bg-orange-500/10" :
|
||||
"bg-white/[0.03] border-border/50 hover:bg-white/[0.06]"
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 flex-shrink-0 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{ev.severity || "—"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-semibold text-card-foreground">{ev.event_type || "Unknown"}</span>
|
||||
<span className="font-mono text-blue-400">{ev.ip_address || "—"}</span>
|
||||
{ev.mac_address && <span className="font-mono text-muted-foreground">{ev.mac_address}</span>}
|
||||
</div>
|
||||
{ev.description && <p className="text-muted-foreground leading-relaxed">{ev.description}</p>}
|
||||
</div>
|
||||
<span className="text-slate-600 flex-shrink-0 whitespace-nowrap">{ev.event_at?.slice(0, 16) || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
<AgentEventsTab events={data.events} />
|
||||
)}
|
||||
|
||||
{/* MAC BANDWIDTH */}
|
||||
{activeTab === "mac" && (
|
||||
data.mac_bandwidth.length === 0 ? <EmptyState msg="No MAC bandwidth data for this agent." /> : (
|
||||
<div className="space-y-2">
|
||||
{data.mac_bandwidth.map((m, i) => {
|
||||
const maxTotal = Math.max(...data.mac_bandwidth.map(x => x.total ?? 0), 1);
|
||||
const pct = ((m.total ?? 0) / maxTotal) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Wifi className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-xs text-slate-200">{m.mac_address}</p>
|
||||
<p className="text-xs text-muted-foreground">{m.manufacturer || "Unknown Manufacturer"}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-card-foreground font-medium">{fmtBytes(m.total ?? 0)}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs mb-2">
|
||||
<span className="text-cyan-400">↓ {fmtBytes(m.download ?? 0)}</span>
|
||||
<span className="text-green-400">↑ {fmtBytes(m.upload ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
<AgentMacTab macBandwidth={data.mac_bandwidth} />
|
||||
)}
|
||||
|
||||
{activeTab === "telemetry" && (
|
||||
<AgentTelemetryTab agentUuid={agentUuid} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
@@ -608,14 +216,19 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
{selectedDevice && selectedDevice.ip && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip}
|
||||
deviceLabel={selectedDevice.label}
|
||||
macAddress={selectedDevice.mac}
|
||||
mac={selectedDevice.mac || ''}
|
||||
deviceData={{ agent_uuid: selectedDevice.agent_uuid || agentUuid } as any}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
{selectedAppLabel && (
|
||||
<AppDetailModal
|
||||
appLabel={selectedAppLabel}
|
||||
favicon={getAppFavicon(selectedAppLabel)}
|
||||
agentUuid={agentUuid}
|
||||
onClose={() => setSelectedAppLabel(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Monitor, ChevronRight } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
devices: any[];
|
||||
onSelectDevice: (device: { ip: string; label: string | null; mac: string | null }) => void;
|
||||
}
|
||||
|
||||
function riskStyle(level: string | null) {
|
||||
switch ((level || "").toLowerCase()) {
|
||||
case "vulnerable":
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "moderate":
|
||||
case "medium": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "safe":
|
||||
case "low": return { badge: "bg-green-500/15 text-green-400 ring-green-500/30", dot: "bg-green-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentDevicesTab({ devices, onSelectDevice }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(devices.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = devices.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{paginated.map((d, i) => {
|
||||
const rc = riskStyle(d.risk_level);
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelectDevice({ ip: d.ip_address || "", label: d.device_label, mac: d.mac_address })}
|
||||
className="w-full text-left p-3 bg-white/[0.03] hover:bg-white/[0.07] hover:border-cyan-500/30 border border-border/50 rounded-xl transition-all group cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 p-2 bg-slate-800/60 group-hover:bg-cyan-500/10 rounded-lg transition-colors">
|
||||
<Monitor className="w-4 h-4 text-muted-foreground group-hover:text-cyan-400 transition-colors" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors" title={d.device_label || d.ip_address || "Unknown"}>
|
||||
{d.device_label || d.ip_address || "Unknown"}
|
||||
</p>
|
||||
{d.risk_level && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 ${rc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
|
||||
{d.risk_level}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
<p className="text-xs text-muted-foreground font-mono">{d.ip_address}</p>
|
||||
{d.mac_address && <p className="text-xs text-muted-foreground font-mono">{d.mac_address}</p>}
|
||||
{d.os_label && <p className="text-xs text-blue-400">{d.os_label}</p>}
|
||||
{d.manufacturer && <p className="text-xs text-muted-foreground">{d.manufacturer}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0 space-y-1 min-w-[110px]">
|
||||
<p className="text-xs text-cyan-400 flex items-center justify-end gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">DL:</span> {fmtBytes(d.download ?? 0)}
|
||||
</p>
|
||||
<p className="text-xs text-green-400 flex items-center justify-end gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">UL:</span> {fmtBytes(d.upload ?? 0)}
|
||||
</p>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 ml-auto transition-colors mt-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={devices.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
events: any[];
|
||||
}
|
||||
|
||||
function severityStyle(sev: string | null) {
|
||||
switch ((sev || "").toLowerCase()) {
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "high": return { badge: "bg-orange-500/15 text-orange-400 ring-orange-500/30", dot: "bg-orange-400" };
|
||||
case "warning": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "info": return { badge: "bg-blue-500/15 text-blue-400 ring-blue-500/30", dot: "bg-blue-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentEventsTab({ events }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(events.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = events.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{paginated.map((ev, i) => {
|
||||
const sc = severityStyle(ev.severity);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`p-3 border rounded-xl text-xs transition-colors ${
|
||||
(ev.severity || "").toLowerCase() === "critical"
|
||||
? "bg-red-500/5 border-red-500/20 hover:bg-red-500/10"
|
||||
: (ev.severity || "").toLowerCase() === "high"
|
||||
? "bg-orange-500/5 border-orange-500/20 hover:bg-orange-500/10"
|
||||
: "bg-white/[0.03] border-border/50 hover:bg-white/[0.06]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 flex-shrink-0 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{ev.severity || "—"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-1">
|
||||
<span className="font-semibold text-card-foreground">{ev.event_type || "Unknown"}</span>
|
||||
<span className="font-mono text-blue-400">{ev.ip_address || "—"}</span>
|
||||
{ev.mac_address && <span className="font-mono text-muted-foreground">{ev.mac_address}</span>}
|
||||
</div>
|
||||
{ev.description && <p className="text-muted-foreground leading-relaxed">{ev.description}</p>}
|
||||
</div>
|
||||
<span className="text-slate-600 flex-shrink-0 truncate">
|
||||
{ev.event_at ? ev.event_at.slice(0, 16).replace("T", " ") : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={events.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { IpDetails } from "./IpDetails";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Cell with overflow ellipsis + title tooltip ────────────────────────────────
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
flows: any[];
|
||||
}
|
||||
|
||||
export default function AgentFlowsTab({ flows }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(flows.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = flows.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "Source IP", width: "min-w-[100px]" },
|
||||
{ header: "Destination", width: "min-w-[140px]" },
|
||||
{ header: "App / Domain", width: "min-w-[160px] w-full" },
|
||||
{ header: "Protocol", width: "w-[80px]" },
|
||||
{ header: "Download", width: "w-[90px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[90px]", align: "right" },
|
||||
{ header: "Last Seen", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TableWrap cols={COLS}>
|
||||
{paginated.map((f, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={f.src_ip || "—"} className="font-mono text-blue-400" /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 truncate min-w-0">
|
||||
<div className="font-mono text-slate-300 truncate" title={`${f.dst_ip || "—"}${f.dst_port ? ':' + f.dst_port : ''}`}>
|
||||
{f.dst_ip || "—"}{f.dst_port ? <span className="text-muted-foreground">:{f.dst_port}</span> : ""}
|
||||
</div>
|
||||
{f.dst_ip && <IpDetails ip={f.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 truncate min-w-0">
|
||||
<div className="font-semibold text-purple-300 truncate" title={f.app_label || "—"}>
|
||||
{f.app_label || "—"}
|
||||
</div>
|
||||
{f.domain && (
|
||||
<div className="text-[10px] text-muted-foreground italic truncate" title={f.domain}>
|
||||
{f.domain}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.protocol || "—"} className="text-muted-foreground uppercase text-[10px]" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.last_seen ? f.last_seen.slice(0, 16).replace("T", " ") : "—"} className="text-slate-400" /></td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
{totalPages > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={flows.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Wifi } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
macBandwidth: any[];
|
||||
}
|
||||
|
||||
export default function AgentMacTab({ macBandwidth }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(macBandwidth.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = macBandwidth.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{paginated.map((m, i) => {
|
||||
const maxTotal = Math.max(...macBandwidth.map(x => x.total ?? 0), 1);
|
||||
const pct = ((m.total ?? 0) / maxTotal) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Wifi className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono text-xs text-slate-200">{m.mac_address}</p>
|
||||
<p className="text-xs text-muted-foreground">{m.manufacturer || "Unknown Manufacturer"}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-card-foreground font-medium">{fmtBytes(m.total ?? 0)}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs mb-2">
|
||||
<span className="text-cyan-400">DL: {fmtBytes(m.download ?? 0)}</span>
|
||||
<span className="text-green-400">UL: {fmtBytes(m.upload ?? 0)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={macBandwidth.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Lock, Eye, Router } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { IpDetails } from "./IpDetails";
|
||||
|
||||
interface Props {
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ msg }: { msg: string }) {
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">{msg}</p>;
|
||||
}
|
||||
|
||||
function severityStyle(sev: string | null) {
|
||||
switch ((sev || "").toLowerCase()) {
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "high": return { badge: "bg-orange-500/15 text-orange-400 ring-orange-500/30", dot: "bg-orange-400" };
|
||||
case "warning": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "info": return { badge: "bg-blue-500/15 text-blue-400 ring-blue-500/30", dot: "bg-blue-400" };
|
||||
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
|
||||
}
|
||||
}
|
||||
|
||||
export default function AgentSecurityTab({ data }: Props) {
|
||||
const security = data?.security;
|
||||
if (!security) return <EmptyState msg="No security data available." />;
|
||||
|
||||
const INSECURE_COLS: ColDef[] = [
|
||||
{ header: "IP", width: "min-w-[100px]" },
|
||||
{ header: "Protocol", width: "min-w-[80px]" },
|
||||
{ header: "Risk", width: "min-w-[100px]" },
|
||||
{ header: "App", width: "min-w-[120px] w-full" },
|
||||
{ header: "Dst IP:Port", width: "min-w-[140px]" },
|
||||
{ header: "Download", width: "w-[80px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[80px]", align: "right" },
|
||||
];
|
||||
|
||||
const REPUTATION_COLS: ColDef[] = [
|
||||
{ header: "Remote IP", width: "min-w-[120px]" },
|
||||
{ header: "Local IP", width: "min-w-[120px]" },
|
||||
{ header: "Reputation", width: "min-w-[140px] w-full" },
|
||||
{ header: "Score", width: "w-[80px]", align: "center" },
|
||||
{ header: "Country", width: "w-[80px]" },
|
||||
{ header: "Status", width: "w-[100px]" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Insecure Protocols */}
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<UnlockIcon className="w-4 h-4 text-orange-400" /> Insecure Protocols
|
||||
<span className="ml-auto text-xs text-muted-foreground">{security.insecure_protocols?.length ?? 0} records</span>
|
||||
</h4>
|
||||
{(security.insecure_protocols?.length ?? 0) === 0 ? <EmptyState msg="No insecure protocols detected for this agent." /> : (
|
||||
<TableWrap cols={INSECURE_COLS}>
|
||||
{security.insecure_protocols.map((r: any, i: number) => {
|
||||
const sc = severityStyle(r.risk);
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-blue-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.protocol || "—"} className="text-slate-200" /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] uppercase font-semibold tracking-wide ring-1 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{r.risk || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.app_label || "—"} className="text-purple-300" /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 truncate min-w-0">
|
||||
<div className="font-mono text-muted-foreground truncate" title={r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}>
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</div>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(r.download)} className="text-cyan-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(r.upload)} className="text-green-400 font-mono" right /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* IP Reputation */}
|
||||
{(security.ip_reputation?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<h4 className="flex items-center gap-2 text-sm font-semibold text-card-foreground mb-3">
|
||||
<Eye className="w-4 h-4 text-purple-400" /> IP Reputation
|
||||
<span className="ml-auto text-xs text-muted-foreground">{security.ip_reputation.length} records</span>
|
||||
</h4>
|
||||
<TableWrap cols={REPUTATION_COLS}>
|
||||
{security.ip_reputation.map((r: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-red-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.local_ip || "—"} className="font-mono text-blue-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.reputation || "—"} className="text-slate-300" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.score ?? "—"} className="text-yellow-400 font-mono" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.country || "—"} className="text-muted-foreground" /></td>
|
||||
<td className="px-3 py-2.5 text-xs">
|
||||
{r.blacklisted ? <span className="text-red-400 font-semibold">Blocked</span> : <span className="text-green-400">Allowed</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnlockIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 9.9-1"></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// src/components/ui/AgentTelemetryTab.tsx
|
||||
"use client";
|
||||
|
||||
import { useTimeline } from "@/lib/api-with-context";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend
|
||||
} from "recharts";
|
||||
|
||||
interface Props {
|
||||
agentUuid: string;
|
||||
}
|
||||
|
||||
export default function AgentTelemetryTab({ agentUuid }: Props) {
|
||||
const { data, isLoading } = useTimeline(60, agentUuid);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-20 text-muted-foreground text-sm">
|
||||
No telemetry data recorded for this agent in the last 24 hours.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Format time labels for XAxis
|
||||
const chartData = data.map(pt => {
|
||||
const date = new Date(pt.fetched_at);
|
||||
const dateStr = date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit' });
|
||||
const timeStr = date.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
||||
const xAxisLabel = `${dateStr} ${timeStr}`;
|
||||
|
||||
const rawFullLabel = date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const fullLabel = rawFullLabel.replace(' at ', ', ') + ' WIB';
|
||||
|
||||
return {
|
||||
...pt,
|
||||
xAxisLabel,
|
||||
fullLabel,
|
||||
flows: pt.total_flows ?? 0,
|
||||
peak: pt.peak_flow_rate ?? 0,
|
||||
drops: pt.packet_drops ?? 0,
|
||||
cpu: pt.cpu_usage ?? 0,
|
||||
memory: pt.memory_usage ?? 0,
|
||||
queue: pt.queue_depth ?? 0
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 p-4">
|
||||
{/* Flow Rates Chart */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-blue-400">Flow Rates & Telemetry Peak</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="flowsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="peakGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="flows" name="Active Flows" stroke="#3b82f6" fillOpacity={1} fill="url(#flowsGrad)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="peak" name="Peak Flow Rate" stroke="#8b5cf6" fillOpacity={1} fill="url(#peakGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packet Drops Chart */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-rose-400">Packet Drop Rate Trend</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="dropsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="drops" name="Packet Drops (pkts/sec)" stroke="#f43f5e" fillOpacity={1} fill="url(#dropsGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Resource Telemetry (CPU & Memory) */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-emerald-400">Agent Resource Telemetry (CPU & RAM)</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="memGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} unit="%" />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="cpu" name="CPU Usage (%)" stroke="#10b981" fillOpacity={1} fill="url(#cpuGrad)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="memory" name="Memory Usage (%)" stroke="#f59e0b" fillOpacity={1} fill="url(#memGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Network Queue Depth */}
|
||||
<div className="bg-slate-950/40 border border-slate-900/60 p-5 rounded-2xl">
|
||||
<h4 className="text-sm font-bold text-white mb-4 tracking-wide uppercase text-cyan-400">Network Packet Queue Depth Trend</h4>
|
||||
<div className="h-64 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="queueGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#06b6d4" stopOpacity={0.4}/>
|
||||
<stop offset="95%" stopColor="#06b6d4" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
|
||||
<XAxis dataKey="xAxisLabel" stroke="#64748b" fontSize={11} />
|
||||
<YAxis stroke="#64748b" fontSize={11} />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => {
|
||||
const matched = chartData.find(item => item.xAxisLabel === value);
|
||||
return matched ? matched.fullLabel : value;
|
||||
}}
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', fontWeight: 'bold' }}
|
||||
/>
|
||||
<Legend verticalAlign="top" height={36} />
|
||||
<Area type="monotone" dataKey="queue" name="Queue Depth (packets)" stroke="#06b6d4" fillOpacity={1} fill="url(#queueGrad)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,142 +1,219 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchAppDetails, AppDetails } from "@/lib/api";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, X, Globe, Server, Monitor } from "lucide-react";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import ActiveDurationDisplay from "./ActiveDurationDisplay";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
appLabel: string;
|
||||
favicon?: string | null;
|
||||
category?: string | null;
|
||||
agentUuid?: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AppDetailModal({ appLabel, favicon, category, onClose }: Props) {
|
||||
export function AppDetailModal({ appLabel, favicon, category, agentUuid, onClose }: Props) {
|
||||
const [data, setData] = useState<AppDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
const { timeRange } = useTimeFilter();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 480);
|
||||
setTimeout(() => { onClose(); }, 480);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
fetchAppDetails(appLabel, timeRange)
|
||||
setPage(1); // Reset page on query updates
|
||||
fetchAppDetails(appLabel, timeRange, agentUuid)
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [appLabel, timeRange]);
|
||||
}, [appLabel, timeRange, agentUuid]);
|
||||
|
||||
const topIps = data?.top_ips || [];
|
||||
const totalPages = Math.ceil(topIps.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginatedIps = topIps.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-2xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-green-900/30 to-emerald-900/20 rounded-t-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg ring-1 ring-green-500/30">
|
||||
{favicon ? (
|
||||
<img src={favicon} alt={appLabel} className="w-5 h-5 rounded" />
|
||||
) : (
|
||||
<Globe className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-card-foreground">{appLabel}</h3>
|
||||
{category && <p className="text-xs text-muted-foreground">{category}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all flex-shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-400" />
|
||||
<p className="text-muted-foreground text-sm">Loading application data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
) : data ? (
|
||||
<div className="p-6 space-y-6">
|
||||
|
||||
{/* Total Summary */}
|
||||
<div className="grid grid-cols-2 gap-3 p-4 bg-white/[0.03] border border-border/50 rounded-xl">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Total Download</p>
|
||||
<p className="text-xl font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Total Upload</p>
|
||||
<p className="text-xl font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Devices accessing this app */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-blue-400" />
|
||||
Devices Accessing {appLabel}
|
||||
</h4>
|
||||
{(!data.devices || data.devices.length === 0) ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">No active devices found for this app in the selected time range.</p>
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-3xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-green-900/30 to-emerald-900/20 rounded-t-2xl flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-500/20 rounded-lg ring-1 ring-green-500/30">
|
||||
{favicon ? (
|
||||
<img src={favicon} alt={appLabel} className="w-5 h-5 rounded" />
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-border/50 bg-white/[0.02]">
|
||||
<table className="w-full text-sm text-left whitespace-nowrap">
|
||||
<thead className="bg-secondary/40 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">Timestamp</th>
|
||||
<th className="px-4 py-3 font-medium">Agent</th>
|
||||
<th className="px-4 py-3 font-medium">IP Address</th>
|
||||
<th className="px-4 py-3 font-medium">Hostname</th>
|
||||
<th className="px-4 py-3 font-medium">OS</th>
|
||||
<th className="px-4 py-3 font-medium">MAC Address</th>
|
||||
<th className="px-4 py-3 font-medium">Download</th>
|
||||
<th className="px-4 py-3 font-medium">Upload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{data.devices.map((dev: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-4 py-3 text-muted-foreground">{new Date(dev.timestamp).toLocaleString()}</td>
|
||||
<td className="px-4 py-3">{dev.agent_uuid || 'Global'}</td>
|
||||
<td className="px-4 py-3 font-mono text-blue-300">{dev.ip || '-'}</td>
|
||||
<td className="px-4 py-3">{dev.hostname || '-'}</td>
|
||||
<td className="px-4 py-3">{dev.os || '-'}</td>
|
||||
<td className="px-4 py-3 font-mono text-muted-foreground">{dev.mac || '-'}</td>
|
||||
<td className="px-4 py-3 text-cyan-400">{fmtBytes(dev.download)}</td>
|
||||
<td className="px-4 py-3 text-green-400">{fmtBytes(dev.upload)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Globe className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-card-foreground">{appLabel}</h3>
|
||||
{category && <p className="text-xs text-muted-foreground">{category}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all flex-shrink-0"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-green-400" />
|
||||
<p className="text-muted-foreground text-sm">Loading application data...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
||||
) : data ? (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Total Summary */}
|
||||
<div className="grid grid-cols-2 gap-3 p-4 bg-white/[0.03] border border-border/50 rounded-xl">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Total Download</p>
|
||||
<p className="text-xl font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Total Upload</p>
|
||||
<p className="text-xl font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Devices list */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-blue-400" />
|
||||
Devices Accessing {appLabel}
|
||||
</h4>
|
||||
{topIps.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">No active devices found for this app in the selected time range.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<TableWrap cols={[
|
||||
{ header: "Status / Last Seen", width: "w-[150px]", align: "center" },
|
||||
{ header: "IP Address", width: "min-w-[120px]" },
|
||||
{ header: "Domain", width: "min-w-[160px] w-full" },
|
||||
{ header: "Protocol", width: "w-[80px]", align: "center" },
|
||||
{ header: "Download", width: "w-[100px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[100px]", align: "right" }
|
||||
]}>
|
||||
{paginatedIps.map((dev: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<ActiveDurationDisplay firstSeen={dev.first_seen} lastSeen={dev.last_seen} className="border-0 bg-transparent p-0 mb-0 flex justify-center" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<button
|
||||
onClick={() => setSelectedIp(dev.ip_address || dev.dst_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none truncate text-left"
|
||||
title={dev.ip_address || dev.dst_ip || '-'}
|
||||
>
|
||||
{dev.ip_address || dev.dst_ip || '-'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={dev.domain || '-'} className="text-slate-300" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={dev.protocol || '-'} className="text-slate-400 font-mono uppercase text-[10px]" center />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={fmtBytes(dev.download)} className="text-cyan-400 font-mono" right />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={fmtBytes(dev.upload)} className="text-green-400 font-mono" right />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={topIps.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedIp && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedIp}
|
||||
onClose={() => setSelectedIp(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
import { Search, Download } from "lucide-react";
|
||||
|
||||
export interface Column<T> {
|
||||
header: React.ReactNode;
|
||||
@@ -9,6 +9,12 @@ export interface Column<T> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface CsvExportOptions<T> {
|
||||
filename: string;
|
||||
headers: string[];
|
||||
rowSerializer: (row: T) => (string | number | null | undefined)[];
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
@@ -17,6 +23,28 @@ interface DataTableProps<T> {
|
||||
isLoading?: boolean;
|
||||
getRowClassName?: (row: T) => string;
|
||||
pageSize?: number;
|
||||
csvExport?: CsvExportOptions<T>;
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
function escapeCsvCell(val: string | number | null | undefined): string {
|
||||
const str = val == null ? "" : String(val);
|
||||
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, rows: (string | number | null | undefined)[][]): void {
|
||||
const bom = "\uFEFF";
|
||||
const csv = bom + rows.map(row => row.map(escapeCsvCell).join(",")).join("\r\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
@@ -26,7 +54,9 @@ export function DataTable<T>({
|
||||
searchFilter,
|
||||
isLoading,
|
||||
getRowClassName,
|
||||
pageSize = 50
|
||||
pageSize = 50,
|
||||
csvExport,
|
||||
onRowClick,
|
||||
}: DataTableProps<T>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -39,30 +69,51 @@ export function DataTable<T>({
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const handleCsvExport = () => {
|
||||
if (!csvExport) return;
|
||||
const rows = [
|
||||
csvExport.headers,
|
||||
...filteredData.map(row => csvExport.rowSerializer(row)),
|
||||
];
|
||||
downloadCsv(csvExport.filename, rows);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{searchFilter && (
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl border border-border overflow-hidden bg-card/50 backdrop-blur-xl shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
{searchFilter && (
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{csvExport && (
|
||||
<button
|
||||
onClick={handleCsvExport}
|
||||
title={`Export ${filteredData.length} rows to CSV`}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/70 hover:text-emerald-300 transition-all shadow-sm whitespace-nowrap"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export CSV ({filteredData.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/50 backdrop-blur-xl shadow-sm overflow-visible">
|
||||
<div className="w-full overflow-visible">
|
||||
<table className="w-full text-sm text-left table-fixed">
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50">
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50 relative z-20">
|
||||
<tr>
|
||||
{columns.map((col, i) => (
|
||||
<th key={i} className={`px-2 py-3.5 font-medium tracking-wide text-center ${col.className || ""}`}>
|
||||
<th key={i} className={`px-4 py-3.5 h-[48px] font-medium tracking-wide whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
@@ -98,10 +149,11 @@ export function DataTable<T>({
|
||||
paginatedData.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={`h-[64px] hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`hover:bg-primary/5 border-b border-border/50 transition-colors duration-200 group h-[48px] ${onRowClick ? "cursor-pointer hover:bg-primary/10" : ""} ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} className={`px-2 py-3 text-center text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
<td key={j} className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground group-hover:text-white transition-colors truncate whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
</td>
|
||||
))}
|
||||
@@ -112,7 +164,6 @@ export function DataTable<T>({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{filteredData.length > pageSize && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
apps: any[];
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function DeviceAppsTab({ apps, totalDownload, totalUpload }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(apps.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = apps.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Box className="w-12 h-12 text-muted-foreground/60 mx-auto mb-3" />
|
||||
<h4 className="text-card-foreground font-bold text-lg mb-1">No Application Traffic</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
No application-specific traffic has been classified for this IP yet, or data is currently being ingested from the network sensor.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((app: any, i: number) => {
|
||||
const appBytes = app.download + app.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-slate-50/50 dark:bg-white/[0.03] border border-border p-4 rounded-2xl hover:bg-slate-100/50 dark:hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-blue-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-card-foreground mb-3 text-sm flex justify-between items-center">
|
||||
{app.app_label}
|
||||
<span className="text-[10px] font-mono text-blue-600 dark:text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full ring-1 ring-blue-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-medium">{fmtBytes(app.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(app.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={apps.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,150 +2,32 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchDeviceDetails, DeviceDetails, DeviceStat } from "@/lib/api";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, X, Monitor, Activity, Shield, Box, Server, Cpu, Clock, HardDrive, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Globe, Link2 } from "lucide-react";
|
||||
import ActiveDurationDisplay from "./ActiveDurationDisplay";
|
||||
|
||||
const PORT_ALIASES: Record<string, string> = {
|
||||
"20": "FTP Data",
|
||||
"21": "FTP Control",
|
||||
"22": "SSH",
|
||||
"23": "Telnet",
|
||||
"25": "SMTP",
|
||||
"53": "DNS",
|
||||
"67": "DHCP Server",
|
||||
"68": "DHCP Client",
|
||||
"80": "HTTP",
|
||||
"110": "POP3",
|
||||
"123": "NTP",
|
||||
"143": "IMAP",
|
||||
"161": "SNMP",
|
||||
"443": "HTTPS",
|
||||
"445": "SMB",
|
||||
"500": "IKE/IPsec",
|
||||
"1433": "MS SQL Server",
|
||||
"1521": "Oracle DB",
|
||||
"3306": "MySQL",
|
||||
"3389": "RDP",
|
||||
"5432": "PostgreSQL",
|
||||
"6379": "Redis",
|
||||
"8080": "HTTP Alternate",
|
||||
"8443": "HTTPS Alternate",
|
||||
"5222": "XMPP/Jabber"
|
||||
};
|
||||
|
||||
function formatProtocolLabel(label: string) {
|
||||
if (label.startsWith("Port ")) {
|
||||
const portNum = label.split(" ")[1];
|
||||
|
||||
// Check known aliases
|
||||
if (PORT_ALIASES[portNum]) {
|
||||
return `Port ${portNum} (${PORT_ALIASES[portNum]})`;
|
||||
}
|
||||
|
||||
// Check ephemeral range (49152 to 65535)
|
||||
const portInt = parseInt(portNum, 10);
|
||||
if (!isNaN(portInt)) {
|
||||
if (portInt >= 49152 && portInt <= 65535) {
|
||||
return `Port ${portNum} (Ephemeral/Dynamic)`;
|
||||
} else {
|
||||
return `Port ${portNum} (Unassigned/Other)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
function formatActiveDuration(firstSeen?: string, lastSeen?: string) {
|
||||
if (!firstSeen || !lastSeen) return null;
|
||||
const start = new Date(firstSeen).getTime();
|
||||
const end = new Date(lastSeen).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
const dateObj = new Date(lastSeen);
|
||||
const fullTimestamp = dateObj.toLocaleString("id-ID", {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
|
||||
let diffSec = Math.floor((end - start) / 1000);
|
||||
if (diffSec < 0) diffSec = 0;
|
||||
|
||||
const h = Math.floor(diffSec / 3600);
|
||||
const m = Math.floor((diffSec % 3600) / 60);
|
||||
const s = diffSec % 60;
|
||||
|
||||
let durationText = "Just now";
|
||||
if (h > 0) durationText = `${h}h ${m}m ${s}s`;
|
||||
else if (m > 0) durationText = `${m}m ${s}s`;
|
||||
else if (s > 0) durationText = `${s}s`;
|
||||
|
||||
// If last seen was within 5 minutes (300 seconds), consider it Active
|
||||
const timeSinceLastSeen = Math.floor((now - end) / 1000);
|
||||
const isActive = timeSinceLastSeen <= 300;
|
||||
|
||||
let lastSeenText = "";
|
||||
if (!isActive) {
|
||||
const agoH = Math.floor(timeSinceLastSeen / 3600);
|
||||
const agoM = Math.floor((timeSinceLastSeen % 3600) / 60);
|
||||
if (agoH > 24) {
|
||||
const agoD = Math.floor(agoH / 24);
|
||||
lastSeenText = `${agoD}d ago`;
|
||||
} else if (agoH > 0) {
|
||||
lastSeenText = `${agoH}h ago`;
|
||||
} else if (agoM > 0) {
|
||||
lastSeenText = `${agoM}m ago`;
|
||||
} else {
|
||||
lastSeenText = "a few seconds ago";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
durationText,
|
||||
fullTimestamp,
|
||||
lastSeenText
|
||||
};
|
||||
}
|
||||
|
||||
function ActiveDurationDisplay({ firstSeen, lastSeen }: { firstSeen?: string, lastSeen?: string }) {
|
||||
if (!firstSeen || !lastSeen) return null;
|
||||
const dur = formatActiveDuration(firstSeen, lastSeen);
|
||||
if (!dur) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 bg-black/20 p-2.5 rounded-lg border border-white/5 mb-3">
|
||||
<div className="flex justify-between text-xs items-center">
|
||||
<span className={`flex items-center gap-1.5 ${dur.isActive ? 'text-blue-400' : 'text-slate-400'}`}>
|
||||
<Clock className="w-3.5 h-3.5" /> {dur.isActive ? "Active" : "Last seen"}
|
||||
</span>
|
||||
<span className="text-slate-300 font-medium">
|
||||
{dur.isActive ? dur.durationText : dur.lastSeenText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 text-right">
|
||||
{dur.fullTimestamp}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import DeviceAppsTab from "./DeviceAppsTab";
|
||||
import DeviceProtocolsTab from "./DeviceProtocolsTab";
|
||||
import DeviceDomainsTab from "./DeviceDomainsTab";
|
||||
import DeviceFlowsTab from "./DeviceFlowsTab";
|
||||
import DeviceThreatsTab from "./DeviceThreatsTab";
|
||||
|
||||
interface Props {
|
||||
ip: string;
|
||||
mac?: string;
|
||||
deviceData?: DeviceStat;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "info" | "apps" | "protocols" | "domains" | "flows" | "threats";
|
||||
|
||||
export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props) {
|
||||
const [data, setData] = useState<DeviceDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("info");
|
||||
const { timeRange } = useTimeFilter();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
@@ -155,10 +37,10 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
const loadData = async (silent = false) => {
|
||||
if (!silent) setIsLoading(true);
|
||||
else setIsRefreshing(true);
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchDeviceDetails(ip, timeRange);
|
||||
const agentUuid = deviceData?.agent_uuid || '';
|
||||
const res = await fetchDeviceDetails(ip, '1d', mac, agentUuid);
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
if (!silent) setError(e.message);
|
||||
@@ -169,13 +51,8 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// Real-time polling every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
loadData(true);
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [ip, timeRange]);
|
||||
loadData(false);
|
||||
}, [ip]);
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "info", label: "Overview", icon: Monitor },
|
||||
@@ -190,47 +67,49 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/60 backdrop-blur-md ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-slate-950/80 border border-slate-800/60 rounded-3xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-blue-900/20 overflow-hidden backdrop-blur-xl ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
className={`relative bg-card border border-border rounded-3xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/10 dark:shadow-blue-900/20 overflow-hidden backdrop-blur-xl ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Decorative Top Glow */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5 bg-gradient-to-br from-blue-900/10 via-slate-900/20 to-slate-950/40 relative z-10">
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-border bg-gradient-to-br from-blue-500/5 via-slate-500/5 to-slate-500/10 dark:from-blue-900/10 dark:via-slate-900/20 dark:to-slate-950/40 relative z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-blue-500 blur-xl opacity-20 rounded-full" />
|
||||
<div className="p-3 bg-gradient-to-br from-blue-500/20 to-cyan-500/10 rounded-xl ring-1 ring-blue-500/30 relative">
|
||||
<Monitor className="w-6 h-6 text-blue-400" />
|
||||
<Monitor className="w-6 h-6 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-white tracking-tight">{ip}</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-blue-400 animate-spin" />}
|
||||
<h3 className="text-xl font-bold text-card-foreground tracking-tight">{ip}</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 animate-spin" />}
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 mt-0.5">
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{data?.device_label || deviceData?.device_label || 'Unknown Device'}
|
||||
<span className="text-slate-600 mx-2">•</span>
|
||||
<span className="text-border mx-2">•</span>
|
||||
<span className="font-mono text-xs">{data?.mac_address || deviceData?.mac_address || 'No MAC'}</span>
|
||||
</p>
|
||||
{data?.agent_label && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1 flex items-center gap-1">
|
||||
<Server className="w-3.5 h-3.5" />
|
||||
Source Agent: <strong>{data.agent_label}</strong> <span className="text-muted-foreground/60">({data.agent_uuid})</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2.5 text-slate-400 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||
>
|
||||
<button onClick={handleClose} className="p-2.5 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-col flex-1 min-h-0 bg-gradient-to-b from-transparent to-slate-950/50 relative z-10">
|
||||
<div className="flex flex-col flex-1 min-h-0 bg-gradient-to-b from-transparent to-muted/20 relative z-10">
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-4">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-blue-500" />
|
||||
<p className="text-slate-400 font-medium">Gathering device telemetry...</p>
|
||||
<p className="text-muted-foreground font-medium">Gathering device telemetry...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6 text-center">
|
||||
@@ -243,7 +122,7 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-white/5 overflow-x-auto custom-scrollbar flex-shrink-0 bg-black/20">
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-border overflow-x-hidden custom-scrollbar flex-shrink-0 bg-secondary/30 flex-wrap">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const isActive = activeTab === t.id;
|
||||
@@ -253,11 +132,11 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`flex items-center gap-2.5 px-4 py-2 rounded-xl text-sm font-semibold transition-all duration-300 ${
|
||||
isActive
|
||||
? "bg-gradient-to-r from-blue-500/20 to-cyan-500/10 text-blue-300 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.15)]"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/5 border border-transparent"
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-300 border border-blue-500/30"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${isActive ? 'text-blue-400' : ''}`} />
|
||||
<Icon className={`w-4 h-4 ${isActive ? 'text-blue-600 dark:text-blue-400' : ''}`} />
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
@@ -266,337 +145,75 @@ export function DeviceDetailModal({ ip, deviceData, onClose }: Props) {
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
|
||||
{/* Info Tab */}
|
||||
{activeTab === "info" && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Traffic Summary */}
|
||||
<div className="space-y-6 animate-fade-in text-xs">
|
||||
<ActiveDurationDisplay firstSeen={data.first_seen || undefined} lastSeen={data.last_seen || undefined} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-6 bg-gradient-to-br from-cyan-500/10 to-transparent border border-cyan-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="p-6 bg-gradient-to-br from-cyan-500/5 dark:from-cyan-500/10 to-transparent border border-cyan-100 dark:border-cyan-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowDownToLine className="w-16 h-16 text-cyan-400" />
|
||||
<ArrowDownToLine className="w-16 h-16 text-cyan-500 dark:text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4" /> Total Download
|
||||
</p>
|
||||
<p className="text-4xl font-black text-white tracking-tight">{fmtBytes(data.total_download)}</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-br from-emerald-500/10 to-transparent border border-emerald-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="p-6 bg-gradient-to-br from-emerald-500/5 dark:from-emerald-500/10 to-transparent border border-emerald-100 dark:border-emerald-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowUpFromLine className="w-16 h-16 text-emerald-400" />
|
||||
<ArrowUpFromLine className="w-16 h-16 text-emerald-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowUpFromLine className="w-4 h-4" /> Total Upload
|
||||
</p>
|
||||
<p className="text-4xl font-black text-white tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Metadata */}
|
||||
<div className="bg-white/[0.02] border border-white/5 rounded-2xl p-6">
|
||||
<h4 className="text-sm font-bold text-slate-300 uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-400" /> Identity & Metadata
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6">
|
||||
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" /> Identity & Metadata
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 flex items-center gap-1.5 mb-1.5">
|
||||
<Cpu className="w-3.5 h-3.5" /> OS
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Cpu className="w-3.5 h-3.5" /> OS</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 flex items-center gap-1.5 mb-1.5">
|
||||
<Monitor className="w-3.5 h-3.5" /> Type
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Monitor className="w-3.5 h-3.5" /> Type</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 flex items-center gap-1.5 mb-1.5">
|
||||
<HardDrive className="w-3.5 h-3.5" /> Manufacturer
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><HardDrive className="w-3.5 h-3.5" /> Manufacturer</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 flex items-center gap-1.5 mb-1.5">
|
||||
<Clock className="w-3.5 h-3.5" /> Last Seen
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Clock className="w-3.5 h-3.5" /> Last Seen</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Activity / Destinations */}
|
||||
<div className="bg-black/40 border border-white/5 rounded-2xl p-6">
|
||||
<h4 className="text-xs font-bold tracking-widest text-blue-400/80 uppercase flex items-center gap-2 mb-4">
|
||||
<Globe className="w-4 h-4" /> Current Activity (Top Destinations)
|
||||
</h4>
|
||||
{(!data.destinations || data.destinations.length === 0) ? (
|
||||
<p className="text-slate-400 text-sm italic">No active destination data available.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{data.destinations.slice(0, 5).map((dst: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 rounded-xl bg-white/[0.02] border border-white/5 hover:bg-white/[0.04] transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/10 flex items-center justify-center border border-blue-500/20">
|
||||
<Activity className="w-4 h-4 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-200 font-mono text-sm">{dst.app_label}</p>
|
||||
<p className="text-slate-500 text-xs">Destination IP</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-right">
|
||||
<div>
|
||||
<p className="text-cyan-400 text-xs font-medium">{fmtBytes(dst.download)}</p>
|
||||
<p className="text-slate-500 text-[10px] uppercase tracking-widest">Down</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-emerald-400 text-xs font-medium">{fmtBytes(dst.upload)}</p>
|
||||
<p className="text-slate-500 text-[10px] uppercase tracking-widest">Up</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apps Tab */}
|
||||
{activeTab === "apps" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.apps || data.apps.length === 0) ? (
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-white/5 rounded-2xl">
|
||||
<Box className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<h4 className="text-slate-300 font-bold text-lg mb-1">No Application Traffic</h4>
|
||||
<p className="text-slate-400 font-medium max-w-md mx-auto">
|
||||
No application-specific traffic has been classified for this IP yet, or data is currently being ingested from the network sensor.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.apps.map((app: any, i: number) => {
|
||||
const totalBytes = data.total_download + data.total_upload;
|
||||
const appBytes = app.download + app.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-white/[0.03] border border-white/5 p-4 rounded-2xl hover:bg-white/[0.05] transition-colors group relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 h-1 bg-blue-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-white mb-3 text-lg flex justify-between items-center">
|
||||
{app.app_label}
|
||||
<span className="text-xs font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full ring-1 ring-blue-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<ActiveDurationDisplay firstSeen={app.first_seen} lastSeen={app.last_seen} />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Download</span>
|
||||
<span className="text-cyan-400 font-mono">{fmtBytes(app.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Upload</span>
|
||||
<span className="text-emerald-400 font-mono">{fmtBytes(app.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceAppsTab apps={data.apps} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{/* Protocols Tab */}
|
||||
{activeTab === "protocols" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.protocols || data.protocols.length === 0) ? (
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-white/5 rounded-2xl">
|
||||
<Link2 className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<h4 className="text-slate-300 font-bold text-lg mb-1">No Protocol Data</h4>
|
||||
<p className="text-slate-400 font-medium max-w-md mx-auto">
|
||||
No distinct protocols or ports have been classified for this IP yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.protocols.map((proto: any, i: number) => {
|
||||
const totalBytes = data.total_download + data.total_upload;
|
||||
const appBytes = proto.download + proto.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-white/[0.03] border border-white/5 p-4 rounded-2xl hover:bg-white/[0.05] transition-colors group relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 h-1 bg-amber-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-white mb-3 text-lg flex justify-between items-center">
|
||||
{formatProtocolLabel(proto.app_label)}
|
||||
<span className="text-xs font-mono text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full ring-1 ring-amber-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<ActiveDurationDisplay firstSeen={proto.first_seen} lastSeen={proto.last_seen} />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Download</span>
|
||||
<span className="text-cyan-400 font-mono">{fmtBytes(proto.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Upload</span>
|
||||
<span className="text-emerald-400 font-mono">{fmtBytes(proto.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceProtocolsTab protocols={data.protocols} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{/* Domains Tab */}
|
||||
{activeTab === "domains" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.domains || data.domains.length === 0) ? (
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-white/5 rounded-2xl">
|
||||
<Globe className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<h4 className="text-slate-300 font-bold text-lg mb-1">No Web/Domain Traffic</h4>
|
||||
<p className="text-slate-400 font-medium max-w-md mx-auto">
|
||||
No distinct domain names have been extracted from SNI or DNS data for this IP yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data.domains.map((dom: any, i: number) => {
|
||||
const totalBytes = data.total_download + data.total_upload;
|
||||
const appBytes = dom.download + dom.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-white/[0.03] border border-white/5 p-4 rounded-2xl hover:bg-white/[0.05] transition-colors group relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 h-1 bg-purple-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-white mb-3 text-lg flex justify-between items-center truncate gap-2" title={dom.app_label}>
|
||||
<span className="truncate">{dom.app_label}</span>
|
||||
<span className="text-xs font-mono text-purple-400 bg-purple-500/10 px-2 py-0.5 rounded-full ring-1 ring-purple-500/20 shrink-0">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<ActiveDurationDisplay firstSeen={dom.first_seen} lastSeen={dom.last_seen} />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Download</span>
|
||||
<span className="text-cyan-400 font-mono">{fmtBytes(dom.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-slate-500">Upload</span>
|
||||
<span className="text-emerald-400 font-mono">{fmtBytes(dom.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceDomainsTab domains={data.domains} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{/* Flows Tab */}
|
||||
{activeTab === "flows" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.flows || data.flows.length === 0) ? (
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-white/5 rounded-2xl">
|
||||
<Activity className="w-12 h-12 text-slate-600 mx-auto mb-3" />
|
||||
<h4 className="text-slate-300 font-bold text-lg mb-1">No Flow Data Yet</h4>
|
||||
<p className="text-slate-400 font-medium max-w-md mx-auto">
|
||||
Network flows for this IP haven't been captured or are currently being ingested. If you just started the system, please wait a few moments as data is actively being synchronized.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 overflow-hidden shadow-xl">
|
||||
<div className="overflow-x-auto custom-scrollbar max-h-[60vh]">
|
||||
<table className="w-full text-sm text-left whitespace-nowrap">
|
||||
<thead className="bg-slate-900/80 text-slate-300 sticky top-0 backdrop-blur-md border-b border-white/10 z-10">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-semibold">Time</th>
|
||||
<th className="px-5 py-4 font-semibold">Source</th>
|
||||
<th className="px-5 py-4 font-semibold">Destination</th>
|
||||
<th className="px-5 py-4 font-semibold">Port</th>
|
||||
<th className="px-5 py-4 font-semibold">App / Protocol</th>
|
||||
<th className="px-5 py-4 font-semibold text-right">Download</th>
|
||||
<th className="px-5 py-4 font-semibold text-right">Upload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{data.flows.map((f: any, i: number) => {
|
||||
const isOutbound = f.src_ip === ip;
|
||||
return (
|
||||
<tr key={i} className="hover:bg-blue-500/5 transition-colors">
|
||||
<td className="px-5 py-3 text-slate-400 text-xs">{new Date(f.timestamp).toLocaleString()}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`font-mono ${isOutbound ? 'text-blue-300 font-semibold' : 'text-slate-400'}`}>{f.src_ip}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`font-mono ${!isOutbound ? 'text-blue-300 font-semibold' : 'text-slate-400'}`}>{f.dst_ip}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-amber-200/80 font-mono text-xs">{f.dst_port || '-'}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className="bg-white/10 text-slate-200 px-2.5 py-1 rounded-md text-xs font-medium border border-white/5">
|
||||
{f.app_label || f.protocol || "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-cyan-400 font-mono text-xs">{fmtBytes(f.download)}</td>
|
||||
<td className="px-5 py-3 text-right text-emerald-400 font-mono text-xs">{fmtBytes(f.upload)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceFlowsTab flows={data.flows} ip={ip} />
|
||||
)}
|
||||
|
||||
{/* Threats Tab */}
|
||||
{activeTab === "threats" && (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{(!data.threats || data.threats.length === 0) ? (
|
||||
<div className="text-center py-12 bg-emerald-500/5 border border-emerald-500/20 rounded-2xl">
|
||||
<Shield className="w-14 h-14 text-emerald-400 mx-auto mb-4" />
|
||||
<h4 className="text-emerald-400 font-bold text-lg mb-1">No Threats Detected</h4>
|
||||
<p className="text-emerald-300/70 text-sm max-w-md mx-auto">
|
||||
This device's traffic appears secure. Keep in mind that new threat intelligence data may take a few moments to sync after a system restart.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 overflow-hidden">
|
||||
<table className="w-full text-sm text-left whitespace-nowrap">
|
||||
<thead className="bg-slate-900/80 text-slate-300 sticky top-0 backdrop-blur-md border-b border-white/10 z-10">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-semibold">Time</th>
|
||||
<th className="px-5 py-4 font-semibold">Type</th>
|
||||
<th className="px-5 py-4 font-semibold">Severity</th>
|
||||
<th className="px-5 py-4 font-semibold">Target IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{data.threats.map((t: any, i: number) => {
|
||||
const sev = (t.severity || '').toLowerCase();
|
||||
let badgeCls = "bg-slate-500/20 text-slate-300 ring-slate-500/30";
|
||||
if (sev === 'high' || sev === 'critical') badgeCls = "bg-red-500/20 text-red-400 ring-red-500/30";
|
||||
else if (sev === 'medium') badgeCls = "bg-amber-500/20 text-amber-400 ring-amber-500/30";
|
||||
else if (sev === 'low') badgeCls = "bg-blue-500/20 text-blue-400 ring-blue-500/30";
|
||||
|
||||
return (
|
||||
<tr key={i} className="hover:bg-red-500/5 transition-colors">
|
||||
<td className="px-5 py-3 text-slate-400 text-xs">{new Date(t.timestamp).toLocaleString()}</td>
|
||||
<td className="px-5 py-3 font-semibold text-slate-200">{t.threat_type || "-"}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wider ring-1 ${badgeCls}`}>
|
||||
{t.severity || "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 font-mono text-slate-300">{t.dst_ip || "-"}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DeviceThreatsTab threats={data.threats} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
domains: any[];
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function DeviceDomainsTab({ domains, totalDownload, totalUpload }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(domains.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = domains.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Globe className="w-12 h-12 text-muted-foreground/60 mx-auto mb-3" />
|
||||
<h4 className="text-card-foreground font-bold text-lg mb-1">No Web/Domain Traffic</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
No distinct domain names have been extracted from SNI or DNS data for this IP yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((dom: any, i: number) => {
|
||||
const appBytes = dom.download + dom.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-slate-50/50 dark:bg-white/[0.03] border border-border p-4 rounded-2xl hover:bg-slate-100/50 dark:hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-purple-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-card-foreground mb-3 text-sm flex justify-between items-center truncate gap-2" title={dom.app_label}>
|
||||
<span className="truncate">{dom.app_label}</span>
|
||||
<span className="text-[10px] font-mono text-purple-600 dark:text-purple-400 bg-purple-500/10 px-2 py-0.5 rounded-full ring-1 ring-purple-500/20 shrink-0">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-medium">{fmtBytes(dom.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(dom.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={domains.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
flows: any[];
|
||||
ip: string;
|
||||
}
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[60vh] rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[800px]">
|
||||
<thead className="sticky top-0 bg-[#111827] z-10">
|
||||
<tr className="border-b border-border">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceFlowsTab({ flows, ip }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(flows.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = flows.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
if (flows.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Activity className="w-12 h-12 text-muted-foreground/60 mx-auto mb-3" />
|
||||
<h4 className="text-card-foreground font-bold text-lg mb-1">No Flow Data Yet</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
Network flows for this IP haven't been captured or are currently being ingested. If you just started the system, please wait a few moments as data is actively being synchronized.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "Time", width: "w-[130px]", align: "center" },
|
||||
{ header: "Source", width: "min-w-[120px]" },
|
||||
{ header: "Destination", width: "min-w-[120px]" },
|
||||
{ header: "Port", width: "w-[80px]", align: "center" },
|
||||
{ header: "App / Protocol", width: "min-w-[150px] w-full" },
|
||||
{ header: "Download", width: "w-[90px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[90px]", align: "right" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TableWrap cols={COLS}>
|
||||
{paginated.map((f: any, i: number) => {
|
||||
const isOutbound = f.src_ip === ip;
|
||||
const timeStr = f.last_seen ? new Date(f.last_seen).toLocaleString() : '-';
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={timeStr} className="text-muted-foreground" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={f.src_ip} className={`font-mono ${isOutbound ? 'text-blue-400 font-semibold' : 'text-slate-400'}`} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={f.dst_ip} className={`font-mono ${!isOutbound ? 'text-blue-400 font-semibold' : 'text-slate-400'}`} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={f.dst_port || '-'} className="text-amber-400/80 font-mono" center />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className="bg-white/10 text-slate-200 px-2 py-0.5 rounded text-[10px] font-medium border border-white/5 truncate max-w-full inline-block align-middle" title={f.app_label || f.protocol || '-'}>
|
||||
{f.app_label || f.protocol || '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" right /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
{totalPages > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={flows.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
const PORT_ALIASES: Record<string, string> = {
|
||||
"20": "FTP Data", "21": "FTP Control", "22": "SSH", "23": "Telnet",
|
||||
"25": "SMTP", "53": "DNS", "67": "DHCP Server", "68": "DHCP Client",
|
||||
"80": "HTTP", "110": "POP3", "123": "NTP", "143": "IMAP", "161": "SNMP",
|
||||
"443": "HTTPS", "445": "SMB", "500": "IKE/IPsec", "1433": "MS SQL Server",
|
||||
"1521": "Oracle DB", "3306": "MySQL", "3389": "RDP", "5432": "PostgreSQL",
|
||||
"6379": "Redis", "8080": "HTTP Alternate", "8443": "HTTPS Alternate", "5222": "XMPP/Jabber"
|
||||
};
|
||||
|
||||
function formatProtocolLabel(label: string) {
|
||||
if (label.startsWith("Port ")) {
|
||||
const portNum = label.split(" ")[1];
|
||||
if (PORT_ALIASES[portNum]) {
|
||||
return `Port ${portNum} (${PORT_ALIASES[portNum]})`;
|
||||
}
|
||||
const portInt = parseInt(portNum, 10);
|
||||
if (!isNaN(portInt)) {
|
||||
if (portInt >= 49152 && portInt <= 65535) {
|
||||
return `Port ${portNum} (Ephemeral/Dynamic)`;
|
||||
} else {
|
||||
return `Port ${portNum} (Unassigned/Other)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
protocols: any[];
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function DeviceProtocolsTab({ protocols, totalDownload, totalUpload }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(protocols.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = protocols.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
if (protocols.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Link2 className="w-12 h-12 text-muted-foreground/60 mx-auto mb-3" />
|
||||
<h4 className="text-card-foreground font-bold text-lg mb-1">No Protocol Data</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
No distinct protocols or ports have been classified for this IP yet.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((proto: any, i: number) => {
|
||||
const appBytes = proto.download + proto.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-slate-50/50 dark:bg-white/[0.03] border border-border p-4 rounded-2xl hover:bg-slate-100/50 dark:hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-amber-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-card-foreground mb-3 text-sm flex justify-between items-center">
|
||||
{formatProtocolLabel(proto.app_label)}
|
||||
<span className="text-[10px] font-mono text-amber-600 dark:text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full ring-1 ring-amber-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-medium">{fmtBytes(proto.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(proto.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={protocols.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Shield } from "lucide-react";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
interface Props {
|
||||
threats: any[];
|
||||
}
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right";
|
||||
}
|
||||
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[60vh] rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[700px]">
|
||||
<thead className="sticky top-0 bg-[#111827] z-10">
|
||||
<tr className="border-b border-border">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{children}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeviceThreatsTab({ threats }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(threats.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = threats.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
if (threats.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 bg-emerald-500/5 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-2xl">
|
||||
<Shield className="w-14 h-14 text-emerald-600 dark:text-emerald-400 mx-auto mb-4" />
|
||||
<h4 className="text-emerald-700 dark:text-emerald-400 font-bold text-lg mb-1">No Threats Detected</h4>
|
||||
<p className="text-muted-foreground text-sm max-w-md mx-auto">
|
||||
This device's traffic appears secure. Keep in mind that new threat intelligence data may take a few moments to sync after a system restart.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "Time", width: "w-[150px]", align: "center" },
|
||||
{ header: "Type", width: "min-w-[150px] w-full" },
|
||||
{ header: "Severity", width: "w-[100px]", align: "center" },
|
||||
{ header: "Target IP", width: "min-w-[120px]" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TableWrap cols={COLS}>
|
||||
{paginated.map((t: any, i: number) => {
|
||||
const sev = (t.severity || '').toLowerCase();
|
||||
let badgeCls = "bg-slate-500/20 text-slate-300 ring-slate-500/30";
|
||||
if (sev === 'high' || sev === 'critical') badgeCls = "bg-red-500/20 text-red-400 ring-red-500/30";
|
||||
else if (sev === 'medium') badgeCls = "bg-amber-500/20 text-amber-400 ring-amber-500/30";
|
||||
else if (sev === 'low') badgeCls = "bg-blue-500/20 text-blue-400 ring-blue-500/30";
|
||||
const timeStr = t.detected_at ? new Date(t.detected_at).toLocaleString() : '-';
|
||||
return (
|
||||
<tr key={i} className="hover:bg-red-500/5 transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={timeStr} className="text-muted-foreground" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div
|
||||
title={t.description || t.threat_type || "-"}
|
||||
className="font-semibold text-card-foreground text-xs truncate max-w-full cursor-help hover:text-primary transition-colors block"
|
||||
>
|
||||
{t.threat_type || "-"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] uppercase tracking-wider font-semibold ring-1 ${badgeCls}`}>
|
||||
{t.severity || "Unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={t.dst_ip || t.ip_address || "-"} className="font-mono text-muted-foreground" /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
{totalPages > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={threats.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+40
-174
@@ -4,60 +4,14 @@ import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useTheme } from "next-themes";
|
||||
import * as THREE from 'three';
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { getArcTooltip, getPointTooltip } from './GlobeTooltips';
|
||||
import { useGlobeData, GlobeDataInput } from './useGlobeData';
|
||||
|
||||
// Dynamically import Globe to avoid SSR issues
|
||||
const Globe = dynamic(() => import('react-globe.gl'), { ssr: false });
|
||||
|
||||
const COUNTRY_COORDS: Record<string, [number, number]> = {
|
||||
"ID": [113.9213, -0.7893],
|
||||
"US": [-95.7129, 37.0902],
|
||||
"HK": [114.1095, 22.3964],
|
||||
"SG": [103.8198, 1.3521],
|
||||
"JP": [138.2529, 36.2048],
|
||||
"SC": [55.4920, -4.6796],
|
||||
"GB": [-3.4360, 55.3781],
|
||||
"IT": [12.5674, 41.8719],
|
||||
"CN": [104.1954, 35.8617],
|
||||
"KR": [127.7669, 35.9078],
|
||||
"CA": [-106.3468, 56.1304],
|
||||
"LU": [6.1296, 49.8153],
|
||||
"NL": [5.2913, 52.1326],
|
||||
"TR": [35.2433, 38.9637],
|
||||
"UA": [31.1656, 48.3794],
|
||||
"RU": [105.3188, 61.5240],
|
||||
"HU": [19.5033, 47.1625],
|
||||
"BD": [90.3563, 23.6850],
|
||||
"AU": [133.7751, -25.2744],
|
||||
"IN": [78.9629, 20.5937],
|
||||
"DE": [10.4515, 51.1657],
|
||||
"FR": [2.2137, 46.2276],
|
||||
"ES": [-3.7492, 40.4637],
|
||||
"BR": [-51.9253, -14.2350],
|
||||
"MY": [101.9758, 4.2105],
|
||||
"TH": [100.9925, 15.8700],
|
||||
"VN": [108.2772, 14.0583],
|
||||
"PH": [121.7740, 12.8797],
|
||||
"TW": [120.9605, 23.6978],
|
||||
"GR": [21.8243, 39.0742]
|
||||
};
|
||||
|
||||
// Jakarta, Indonesia as Origin
|
||||
const ORIGIN_LAT = -6.2088;
|
||||
const ORIGIN_LNG = 106.8456;
|
||||
|
||||
interface GlobeMapProps {
|
||||
data: {
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
endLat: number;
|
||||
endLng: number;
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
value: number;
|
||||
download: number;
|
||||
upload: number;
|
||||
}[];
|
||||
data: GlobeDataInput[];
|
||||
}
|
||||
|
||||
export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
@@ -116,7 +70,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
controls.addEventListener('start', () => setUserInteracting(true));
|
||||
controls.addEventListener('end', () => setUserInteracting(false));
|
||||
|
||||
// Focus on Southeast Asia initially (or dynamically from first data point if available)
|
||||
// Focus on Southeast Asia initially
|
||||
globeRef.current.pointOfView({ lat: -6.2, lng: 106.8, altitude: 1.8 }, 2000);
|
||||
}
|
||||
}, 100);
|
||||
@@ -163,90 +117,23 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
}, [isLight, customGlobeMaterial]);
|
||||
|
||||
// Prepare Data Memos
|
||||
const arcsData = useMemo(() => {
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
|
||||
}));
|
||||
}, [data, isLight]);
|
||||
const { arcsData, pointsData, labelsData } = useGlobeData(data, isLight);
|
||||
|
||||
const pointsData = useMemo(() => {
|
||||
const points = [];
|
||||
const originSet = new Set();
|
||||
const destSet = new Set();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
points.push({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
size: 0.1,
|
||||
color: isLight ? '#0284c7' : '#38bdf8',
|
||||
name: arc.toLabel,
|
||||
value: arc.value,
|
||||
upload: arc.upload,
|
||||
download: arc.download,
|
||||
isOrigin: false
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
points.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
size: 0.2,
|
||||
color: '#f43f5e',
|
||||
name: arc.fromLabel,
|
||||
value: 0,
|
||||
upload: 0,
|
||||
download: 0,
|
||||
isOrigin: true
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
// Stable polygon color callbacks to prevent mesh rebuilds and fix drag lag (Requirement 1 & 2)
|
||||
const getPolygonCapColor = useMemo(() => {
|
||||
const color = isLight ? '#4ade80' : '#0f172a';
|
||||
return () => color;
|
||||
}, [isLight]);
|
||||
|
||||
return points;
|
||||
}, [arcsData, isLight]);
|
||||
const getPolygonSideColor = useMemo(() => {
|
||||
const color = isLight ? '#22c55e' : '#020617';
|
||||
return () => color;
|
||||
}, [isLight]);
|
||||
|
||||
// Extract All Labels + Origin to display statically
|
||||
const labelsData = useMemo(() => {
|
||||
const labels: any[] = [];
|
||||
const originSet = new Set();
|
||||
const destSet = new Set();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
labels.push({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
name: arc.toLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc'
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
labels.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
name: arc.fromLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc'
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
}, [arcsData, isLight]);
|
||||
const getPolygonStrokeColor = useMemo(() => {
|
||||
const color = isLight ? '#ffffff' : '#0ea5e9';
|
||||
return () => color;
|
||||
}, [isLight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -268,9 +155,9 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
|
||||
polygonsData={countries ? countries.features : []}
|
||||
polygonAltitude={0.015}
|
||||
polygonCapColor={() => isLight ? '#4ade80' : '#0f172a'}
|
||||
polygonSideColor={() => isLight ? '#22c55e' : '#020617'}
|
||||
polygonStrokeColor={() => isLight ? '#ffffff' : '#0ea5e9'}
|
||||
polygonCapColor={getPolygonCapColor}
|
||||
polygonSideColor={getPolygonSideColor}
|
||||
polygonStrokeColor={getPolygonStrokeColor}
|
||||
|
||||
arcsData={arcsData}
|
||||
arcStartLat={(d: any) => d.startLat}
|
||||
@@ -282,31 +169,24 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
arcDashGap={0.2}
|
||||
arcDashAnimateTime={2000}
|
||||
arcAltitude={(d: any) => {
|
||||
const latDiff = Math.abs(d.startLat - d.endLat);
|
||||
const lngDiff = Math.abs(d.startLng - d.endLng);
|
||||
const dist = Math.sqrt(latDiff*latDiff + lngDiff*lngDiff);
|
||||
return Math.max(0.1, dist * 0.005);
|
||||
// Convert lat/lng to radians
|
||||
const phi1 = d.startLat * Math.PI / 180;
|
||||
const lambda1 = d.startLng * Math.PI / 180;
|
||||
const phi2 = d.endLat * Math.PI / 180;
|
||||
const lambda2 = d.endLng * Math.PI / 180;
|
||||
|
||||
// Great-circle angular distance
|
||||
const cosTheta = Math.sin(phi1) * Math.sin(phi2) + Math.cos(phi1) * Math.cos(phi2) * Math.cos(lambda1 - lambda2);
|
||||
const theta = Math.acos(Math.max(-1, Math.min(1, cosTheta)));
|
||||
|
||||
// Chord depth under the sphere surface
|
||||
const chordDepth = 1 - Math.cos(theta / 2);
|
||||
|
||||
// Sleeker hover height that hugs the sphere surface closely (Requirement 2)
|
||||
return chordDepth + 0.02 + (theta * 0.008);
|
||||
}}
|
||||
arcStroke={isLight ? 0.8 : 0.6}
|
||||
arcLabel={(d: any) => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: 180px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #facc15;">
|
||||
${d.fromLabel}
|
||||
</div>
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #38bdf8;">
|
||||
${d.toLabel}
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
arcLabel={(d: any) => getArcTooltip(d)}
|
||||
|
||||
pointsData={pointsData}
|
||||
pointLat="lat"
|
||||
@@ -315,22 +195,7 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
pointAltitude={0.01}
|
||||
pointRadius="size"
|
||||
pointsMerge={true}
|
||||
pointLabel={(d: any) => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: ${d.value > 0 ? '180px' : 'auto'}; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; ${d.value > 0 ? 'border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px;' : ''}">${d.name}</div>
|
||||
${d.value > 0 ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`}
|
||||
pointLabel={(d: any) => getPointTooltip(d)}
|
||||
|
||||
ringsData={pointsData}
|
||||
ringLat="lat"
|
||||
@@ -344,10 +209,11 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
labelLat={(d: any) => d.lat}
|
||||
labelLng={(d: any) => d.lng}
|
||||
labelDotRadius={0}
|
||||
labelSize={1.5}
|
||||
labelSize={(d: any) => d.size || 1.4}
|
||||
labelText="name"
|
||||
labelColor={(d: any) => d.color}
|
||||
labelResolution={2}
|
||||
labelAltitude={0.02} // float labels above polygon altitude to make them visible (Requirement 1)
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
export const getArcTooltip = (d: any): string => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: 180px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #facc15;">
|
||||
${d.fromLabel}
|
||||
</div>
|
||||
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #38bdf8;">
|
||||
${d.toLabel}
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
export const getPointTooltip = (d: any): string => `
|
||||
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: ${d.value > 0 ? '180px' : 'auto'}; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||
<div style="font-weight: 600; font-size: 14px; ${d.value > 0 ? 'border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px;' : ''}">${d.name}</div>
|
||||
${d.value > 0 ? `
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
|
||||
<span style="color: #38bdf8;">Download:</span> <span>${fmtBytes(d.download)}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<span style="color: #4ade80;">Upload:</span> <span>${fmtBytes(d.upload)}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
@@ -32,7 +32,7 @@ export function IpDetails({ ip }: { ip: string }) {
|
||||
setDetails(resolved);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("GeoIP lookup client error:", err);
|
||||
console.warn("GeoIP lookup client error:", err);
|
||||
if (isMounted) {
|
||||
setDetails({
|
||||
isp: "Public IP",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
totalResults: number;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
totalResults,
|
||||
startIndex,
|
||||
endIndex,
|
||||
}: PaginationProps) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-3 border-t border-border/40 bg-white/[0.01] rounded-b-xl text-xs flex-wrap gap-2">
|
||||
<div className="text-muted-foreground">
|
||||
Showing <span className="font-medium text-slate-300">{startIndex + 1}</span> to{" "}
|
||||
<span className="font-medium text-slate-300">{Math.min(endIndex, totalResults)}</span> of{" "}
|
||||
<span className="font-medium text-slate-300">{totalResults}</span> results
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1 rounded bg-secondary hover:bg-secondary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-all border border-border/50 text-slate-300"
|
||||
title="Previous Page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="text-muted-foreground font-medium px-2">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1 rounded bg-secondary hover:bg-secondary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-all border border-border/50 text-slate-300"
|
||||
title="Next Page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// src/components/ui/globeOffsets.ts
|
||||
|
||||
export const SMALL_LABEL_COUNTRIES = new Set([
|
||||
// Europe
|
||||
'GB', 'IE', 'FR', 'DE', 'NL', 'BE', 'LU', 'CH', 'IT', 'ES', 'PT', 'GR', 'TR', 'CY',
|
||||
'NO', 'SE', 'FI', 'DK', 'PL', 'UA', 'BY', 'LT', 'LV', 'EE', 'RO', 'BG', 'HU', 'SK', 'CZ', 'AT', 'MD',
|
||||
// Central America & Caribbean
|
||||
'GP', 'TT', 'PA', 'CR', 'NI', 'HN', 'SV', 'GT', 'BZ', 'JM', 'PR', 'DO', 'HT', 'BS', 'VG', 'DM',
|
||||
// Indian Ocean
|
||||
'MU', 'RE', 'SC'
|
||||
]);
|
||||
|
||||
export function getStaggeredCoordinates(countryCode: string, startLat: number, startLng: number): { lat: number; lng: number } {
|
||||
let lat = startLat;
|
||||
let lng = startLng;
|
||||
|
||||
if (countryCode === 'SG') {
|
||||
lat += 1.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'MY') {
|
||||
lat += 3.0;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'VE') {
|
||||
lat += 2.5;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'CO') {
|
||||
lat += 1.0;
|
||||
lng -= 3.0;
|
||||
} else if (countryCode === 'EC') {
|
||||
lat -= 0.5;
|
||||
lng -= 3.5;
|
||||
} else if (countryCode === 'PE') {
|
||||
lat -= 1.0;
|
||||
lng -= 2.5;
|
||||
} else if (countryCode === 'BO') {
|
||||
lat -= 1.5;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'PY') {
|
||||
lat -= 1.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'UY') {
|
||||
lat -= 2.0;
|
||||
lng += 3.0;
|
||||
} else if (countryCode === 'BR') {
|
||||
lat += 1.5;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'CL') {
|
||||
lat -= 3.0;
|
||||
lng -= 2.0;
|
||||
} else if (countryCode === 'AR') {
|
||||
lat -= 3.5;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'SR') {
|
||||
lat += 1.5;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'GY') {
|
||||
lat += 2.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'PA') {
|
||||
lat -= 1.0;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'GT') {
|
||||
lat += 2.5;
|
||||
lng -= 2.0;
|
||||
} else if (countryCode === 'TT') {
|
||||
lat -= 1.5;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'GP') {
|
||||
lat += 3.0;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'PR') {
|
||||
lat += 1.5;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'VG') {
|
||||
lat += 2.2;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'DO') {
|
||||
lat -= 1.0;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'HT') {
|
||||
lat -= 1.5;
|
||||
lng -= 2.5;
|
||||
} else if (countryCode === 'JM') {
|
||||
lat -= 2.0;
|
||||
lng -= 4.0;
|
||||
} else if (countryCode === 'CR') {
|
||||
lat -= 0.5;
|
||||
lng -= 2.0;
|
||||
} else if (countryCode === 'NI') {
|
||||
lat += 0.5;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'HN') {
|
||||
lat += 1.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'SV') {
|
||||
lat -= 1.5;
|
||||
lng -= 2.5;
|
||||
} else if (countryCode === 'MU') {
|
||||
lat += 0.8;
|
||||
lng += 0.8;
|
||||
} else if (countryCode === 'RE') {
|
||||
lat -= 0.8;
|
||||
lng -= 0.8;
|
||||
} else if (countryCode === 'NO') {
|
||||
lat += 2.0;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'SE') {
|
||||
lat += 3.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'FI') {
|
||||
lat += 4.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'DK') {
|
||||
lat += 0.8;
|
||||
} else if (countryCode === 'NL') {
|
||||
lat += 1.5;
|
||||
lng += 0.5;
|
||||
} else if (countryCode === 'BE') {
|
||||
lat -= 0.5;
|
||||
lng -= 1.2;
|
||||
} else if (countryCode === 'LU') {
|
||||
lat -= 1.5;
|
||||
lng -= 0.8;
|
||||
} else if (countryCode === 'CH') {
|
||||
lat -= 2.5;
|
||||
lng -= 1.0;
|
||||
} else if (countryCode === 'DE') {
|
||||
lat += 1.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'AT') {
|
||||
lat -= 1.5;
|
||||
lng -= 0.5;
|
||||
} else if (countryCode === 'IT') {
|
||||
lat -= 4.0;
|
||||
lng -= 0.5;
|
||||
} else if (countryCode === 'ES') {
|
||||
lat -= 1.5;
|
||||
} else if (countryCode === 'PT') {
|
||||
lat -= 1.0;
|
||||
lng -= 1.5;
|
||||
} else if (countryCode === 'GR') {
|
||||
lat -= 4.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'EE') {
|
||||
lat += 2.0;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'LV') {
|
||||
lat += 1.2;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'LT') {
|
||||
lat += 0.5;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'PL') {
|
||||
lat += 1.0;
|
||||
lng += 1.5;
|
||||
} else if (countryCode === 'UA') {
|
||||
lat += 0.5;
|
||||
lng += 3.0;
|
||||
} else if (countryCode === 'MD') {
|
||||
lat -= 0.2;
|
||||
lng += 2.5;
|
||||
} else if (countryCode === 'BY') {
|
||||
lat += 2.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'RO') {
|
||||
lat -= 1.0;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'BG') {
|
||||
lat -= 2.5;
|
||||
lng += 2.0;
|
||||
} else if (countryCode === 'HU') {
|
||||
lat -= 2.0;
|
||||
lng += 1.0;
|
||||
} else if (countryCode === 'SK') {
|
||||
lat -= 1.2;
|
||||
lng += 0.5;
|
||||
} else if (countryCode === 'CZ') {
|
||||
lat -= 0.5;
|
||||
lng -= 1.0;
|
||||
}
|
||||
|
||||
return { lat, lng };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// src/components/ui/useGlobeData.ts
|
||||
import { useMemo } from 'react';
|
||||
import { SMALL_LABEL_COUNTRIES, getStaggeredCoordinates } from './globeOffsets';
|
||||
|
||||
export interface GlobeDataInput {
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
endLat: number;
|
||||
endLng: number;
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
value: number;
|
||||
download: number;
|
||||
upload: number;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
export function useGlobeData(data: GlobeDataInput[], isLight: boolean) {
|
||||
const arcsData = useMemo(() => {
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
|
||||
}));
|
||||
}, [data, isLight]);
|
||||
|
||||
const pointsData = useMemo(() => {
|
||||
const points: any[] = [];
|
||||
const originSet = new Set<string>();
|
||||
const destSet = new Set<string>();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
points.push({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
size: 0.1,
|
||||
color: isLight ? '#0284c7' : '#38bdf8',
|
||||
name: arc.toLabel,
|
||||
value: arc.value,
|
||||
upload: arc.upload,
|
||||
download: arc.download,
|
||||
isOrigin: false
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
points.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
size: 0.2,
|
||||
color: '#f43f5e',
|
||||
name: arc.fromLabel,
|
||||
value: 0,
|
||||
upload: 0,
|
||||
download: 0,
|
||||
isOrigin: true
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
|
||||
return points;
|
||||
}, [arcsData, isLight]);
|
||||
|
||||
const labelsData = useMemo(() => {
|
||||
const labels: any[] = [];
|
||||
const originSet = new Set();
|
||||
const destSet = new Set();
|
||||
|
||||
arcsData.forEach(arc => {
|
||||
const originKey = `${arc.startLat},${arc.startLng}`;
|
||||
const destKey = `${arc.endLat},${arc.endLng}`;
|
||||
|
||||
if (!destSet.has(destKey)) {
|
||||
// Shift and stagger coordinates (Southeast Asia, Europe, Caribbean, Indian Ocean, Latin America)
|
||||
const { lat, lng } = getStaggeredCoordinates(arc.countryCode || '', arc.endLat, arc.endLng);
|
||||
const size = SMALL_LABEL_COUNTRIES.has(arc.countryCode || '') ? 0.9 : 1.4;
|
||||
|
||||
labels.push({
|
||||
lat,
|
||||
lng,
|
||||
name: arc.toLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc',
|
||||
size
|
||||
});
|
||||
destSet.add(destKey);
|
||||
}
|
||||
|
||||
if (!originSet.has(originKey)) {
|
||||
labels.push({
|
||||
lat: arc.startLat,
|
||||
lng: arc.startLng,
|
||||
name: arc.fromLabel,
|
||||
color: isLight ? '#0f172a' : '#f8fafc',
|
||||
size: 1.4
|
||||
});
|
||||
originSet.add(originKey);
|
||||
}
|
||||
});
|
||||
|
||||
return labels;
|
||||
}, [arcsData, isLight]);
|
||||
|
||||
return { arcsData, pointsData, labelsData };
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
type TimeRange = '5m' | '10m' | '30m' | '1h' | '1d' | '7d' | '30d';
|
||||
type TimeRange = '5m' | '30m' | '1h' | '1d' | '7d';
|
||||
|
||||
interface TimeFilterContextType {
|
||||
timeRange: TimeRange;
|
||||
@@ -12,7 +12,7 @@ interface TimeFilterContextType {
|
||||
const TimeFilterContext = createContext<TimeFilterContextType | undefined>(undefined);
|
||||
|
||||
export function TimeFilterProvider({ children }: { children: ReactNode }) {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('1h');
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('1d');
|
||||
|
||||
return (
|
||||
<TimeFilterContext.Provider value={{ timeRange, setTimeRange }}>
|
||||
|
||||
+38
-88
@@ -23,7 +23,7 @@ const getHeaders = () => {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// Prioritize long-term API Key as recommended by Netify support team
|
||||
// Prioritize long-term API Key as recommended by the DPI API support team
|
||||
if (API_KEY) {
|
||||
headers["x-api-key"] = API_KEY;
|
||||
} else if (JWT_TOKEN) {
|
||||
@@ -57,104 +57,54 @@ export async function getAgents(): Promise<Agent[]> {
|
||||
const API_KEY = getApiKey();
|
||||
const SITE_UUID = getSiteUuid();
|
||||
|
||||
// Fallback to Informatics Scorecard Agent if site-scoped API key is used
|
||||
// All agent data comes from MongoDB (single source of truth per Rule 13)
|
||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const dbPath = path.join(process.cwd(), 'backend', 'netify_data.db');
|
||||
const db = new Database(dbPath);
|
||||
const mongoose = require('mongoose');
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
|
||||
|
||||
// Query the Informatics API dynamically for all active agents
|
||||
let apiAgents: any[] = [];
|
||||
try {
|
||||
const res = await fetch("https://informatics.netify.ai/api/v1/data/stats/top/agent/download?filter_interval=43200&settings_limit=100", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Accept": "application/json",
|
||||
"x-api-key": API_KEY,
|
||||
"x-net-site": SITE_UUID || ""
|
||||
},
|
||||
cache: "no-store"
|
||||
});
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
apiAgents = json.data || [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch dynamic agents from Netify API:", err);
|
||||
// Connect to MongoDB if not already connected
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
}
|
||||
const db = mongoose.connection.db;
|
||||
|
||||
// If API query fails, apiAgents will be empty. We still process from knownAgents.
|
||||
const knownAgents = db.prepare('SELECT DISTINCT agent_uuid FROM users WHERE agent_uuid IS NOT NULL').all().map((r: any) => r.agent_uuid);
|
||||
|
||||
const agentMap = new Map<string, any>();
|
||||
|
||||
// 1. Add agents that have traffic from the API (Active in last 12 hours = Online)
|
||||
apiAgents.forEach((item: any, idx: number) => {
|
||||
const uuid = item.agent?.uuid;
|
||||
if (uuid) {
|
||||
agentMap.set(uuid, {
|
||||
id: item.agent.id,
|
||||
uuid: uuid,
|
||||
label: item.agent.label,
|
||||
isOnline: true
|
||||
});
|
||||
}
|
||||
});
|
||||
// Get all agents from MongoDB summaries collection
|
||||
const agentSummaries = await db.collection('summaries')
|
||||
.aggregate([
|
||||
{ $sort: { timestamp: -1 } },
|
||||
{ $group: { _id: '$agent_uuid', lastSeen: { $first: '$timestamp' }, label: { $first: '$agent_label' } } },
|
||||
]).toArray();
|
||||
|
||||
// 2. Add any other configured agents from DB (These might be Last Seen or Offline)
|
||||
knownAgents.forEach((uuid: string) => {
|
||||
if (!agentMap.has(uuid)) {
|
||||
agentMap.set(uuid, {
|
||||
id: parseInt(uuid.replace(/[^0-9]/g, '')) || Math.floor(Math.random() * 100000), // Fallback ID
|
||||
uuid: uuid,
|
||||
label: uuid, // Label diselesaikan oleh frontend via resolveAgentLabel
|
||||
isOnline: false
|
||||
});
|
||||
}
|
||||
});
|
||||
const agents: Agent[] = agentSummaries.map((item: any, idx: number) => {
|
||||
const lastSeenDate = item.lastSeen ? new Date(item.lastSeen) : null;
|
||||
const nowMs = Date.now();
|
||||
const diffMin = lastSeenDate ? (nowMs - lastSeenDate.getTime()) / 60000 : Infinity;
|
||||
const isOnline = diffMin < 10;
|
||||
const statusHuman = isOnline
|
||||
? 'Online'
|
||||
: lastSeenDate
|
||||
? `Last Seen ${lastSeenDate.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} WIB`
|
||||
: 'Offline';
|
||||
|
||||
const agents: Agent[] = Array.from(agentMap.values()).map((item, idx) => {
|
||||
let statusHuman = "Offline";
|
||||
|
||||
if (item.isOnline) {
|
||||
statusHuman = "Online";
|
||||
} else {
|
||||
// Cari data terakhir dari DB
|
||||
const f = db.prepare('SELECT MAX(fetched_at) as t FROM flows WHERE agent_uuid = ?').get(item.uuid)?.t;
|
||||
const a = db.prepare('SELECT MAX(fetched_at) as t FROM bandwidth_apps WHERE agent_uuid = ?').get(item.uuid)?.t;
|
||||
const e = db.prepare('SELECT MAX(fetched_at) as t FROM events WHERE agent_uuid = ?').get(item.uuid)?.t;
|
||||
|
||||
const times = [f, a, e].filter(Boolean);
|
||||
if (times.length > 0) {
|
||||
times.sort();
|
||||
const latest = times[times.length - 1];
|
||||
const d = new Date(latest);
|
||||
const formatted = d.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
statusHuman = `Last Seen ${formatted} WIB`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id || idx,
|
||||
uuid: item.uuid,
|
||||
serial: item.uuid,
|
||||
site_uuid: SITE_UUID || "",
|
||||
organization_uuid: "",
|
||||
id: idx + 1,
|
||||
uuid: item._id,
|
||||
serial: item._id,
|
||||
site_uuid: SITE_UUID || '',
|
||||
organization_uuid: '',
|
||||
provisioned: true,
|
||||
activated: true,
|
||||
label: item.label,
|
||||
created_at: { human: "N/A", date: "", unix_time: 0 },
|
||||
updated_at: { human: "N/A", date: "", unix_time: 0 },
|
||||
last_seen_at: {
|
||||
human: statusHuman,
|
||||
date: new Date().toISOString(),
|
||||
unix_time: Date.now() / 1000
|
||||
}
|
||||
activated: isOnline,
|
||||
label: item.label || item._id,
|
||||
created_at: { human: 'N/A', date: '', unix_time: 0 },
|
||||
updated_at: { human: 'N/A', date: '', unix_time: 0 },
|
||||
last_seen_at: {
|
||||
human: statusHuman,
|
||||
date: lastSeenDate?.toISOString() || '',
|
||||
unix_time: lastSeenDate ? lastSeenDate.getTime() / 1000 : 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
db.close();
|
||||
return agents;
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -1,4 +1,4 @@
|
||||
// Admin User Management API helpers
|
||||
// Admin User Management API helpers
|
||||
|
||||
export interface ManagedUser {
|
||||
id: number;
|
||||
@@ -46,7 +46,7 @@ export function getViewAsStatusSync(): ViewAsStatus {
|
||||
export async function getAdminUsers(): Promise<ManagedUser[]> {
|
||||
const res = await fetch('/api/auth/admin/users', { credentials: 'include' });
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal mengambil daftar user');
|
||||
if (!json.ok) throw new Error(json.error || 'Failed to retrieve user list');
|
||||
return json.data;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function createAdminAgentUser(data: {
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal membuat akun');
|
||||
if (!json.ok) throw new Error(json.error || 'Failed to create account');
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function updateAdminAgentUser(formData: FormData): Promise<{ ok: bo
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal memperbarui akun');
|
||||
if (!json.ok) throw new Error(json.error || 'Failed to update account');
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function deleteAdminAgentUser(userId: number): Promise<void> {
|
||||
credentials: 'include',
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal menghapus akun');
|
||||
if (!json.ok) throw new Error(json.error || 'Failed to delete account');
|
||||
}
|
||||
|
||||
// POST /api/auth/admin/view-as — simpan token di localStorage
|
||||
@@ -116,8 +116,17 @@ export async function startViewAs(agent_uuid: string, agent_label: string): Prom
|
||||
}
|
||||
}
|
||||
|
||||
// Keluar dari mode view-as — hapus dari localStorage
|
||||
// Keluar dari mode view-as — hapus dari localStorage dan server session
|
||||
export async function stopViewAs(): Promise<void> {
|
||||
try {
|
||||
await fetch('/api/auth/admin/view-as', {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to terminate server view-as session:', err);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(VIEW_AS_KEY);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// src/lib/api-context-core.ts
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetcher } from './api';
|
||||
|
||||
// Global memory cache to prevent UI reloading flicker when changing pages
|
||||
export const globalCache: Record<string, any> = {};
|
||||
|
||||
// Auto-refresh interval: 5 minutes (matches proxy cron schedule)
|
||||
export const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Generic hook factory with:
|
||||
* - timeRange from context
|
||||
* - stale-while-revalidate caching (no spinner if cached data is present)
|
||||
* - auto-refresh every 5 minutes
|
||||
*/
|
||||
export function useCtxFetch<T>(endpoint: string, timeRange: string, defaultValue: T): {
|
||||
data: T;
|
||||
error: Error | null;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const cacheKey = `${endpoint}:${timeRange}`;
|
||||
|
||||
// Initial state reads from global memory cache if present
|
||||
const [data, setData] = useState<T>(() => {
|
||||
return (globalCache[cacheKey] as T) ?? defaultValue;
|
||||
});
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Do not show loading state if we already have cached data in memory
|
||||
const [isLoading, setIsLoading] = useState(() => {
|
||||
return !globalCache[cacheKey];
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
// Auto-refresh every 5 minutes
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTick(t => t + 1), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Listen for device label updates to clear cache and reload in real-time
|
||||
useEffect(() => {
|
||||
const handleLabelUpdated = () => {
|
||||
delete globalCache[cacheKey];
|
||||
setTick(t => t + 1);
|
||||
};
|
||||
window.addEventListener("device-label-updated", handleLabelUpdated);
|
||||
return () => window.removeEventListener("device-label-updated", handleLabelUpdated);
|
||||
}, [cacheKey]);
|
||||
|
||||
// Fetch on mount + timeRange change + auto-refresh tick
|
||||
useEffect(() => {
|
||||
if (!globalCache[cacheKey]) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
fetcher<T>(endpoint, timeRange)
|
||||
.then(res => {
|
||||
const val = res ?? defaultValue;
|
||||
globalCache[cacheKey] = val;
|
||||
setData(val);
|
||||
})
|
||||
.catch(err => {
|
||||
setError(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint, timeRange, tick]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// src/lib/api-detail-types.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// UI Detail Tab and Modal Interface Type Definitions for BackOne Client.
|
||||
// Split from api.ts to respect the 256-line threshold limit.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { FlowStat, AppStat, ThreatStat } from "./api-types";
|
||||
|
||||
export interface AgentDeviceItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
last_seen: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
encrypted_pct: number | null;
|
||||
risk_level: string | null;
|
||||
has_insecure: boolean;
|
||||
}
|
||||
|
||||
export interface AgentFlowItem {
|
||||
flow_id?: string | null;
|
||||
src_ip: string | null;
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
protocol: string | null;
|
||||
app_label?: string | null;
|
||||
domain?: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
last_seen: string | null;
|
||||
}
|
||||
|
||||
export interface AgentAppItem {
|
||||
app_id: number | null;
|
||||
app_label: string;
|
||||
category: string | null;
|
||||
favicon: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
}
|
||||
|
||||
export interface AgentSummary {
|
||||
total_devices: number;
|
||||
active_flows: number;
|
||||
bandwidth_down: number;
|
||||
bandwidth_up: number;
|
||||
}
|
||||
|
||||
export interface AgentEncryptionItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
encrypted_pct: number | null;
|
||||
unencrypted: number | null;
|
||||
encrypted: number | null;
|
||||
total: number | null;
|
||||
risk_level: string | null;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentInsecureProtocolItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
protocol: string | null;
|
||||
risk: string | null;
|
||||
app_label: string | null;
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentUnencryptedPwdItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
protocol: string | null;
|
||||
username: string | null;
|
||||
severity: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentIpReputationItem {
|
||||
ip_address: string | null;
|
||||
local_ip: string | null;
|
||||
mac_address: string | null;
|
||||
reputation: string | null;
|
||||
score: number | null;
|
||||
country: string | null;
|
||||
app_label: string | null;
|
||||
blacklisted: number | boolean;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentTorItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
exit_node: string | null;
|
||||
circuit_id: string | null;
|
||||
country: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentVpnItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
vpn_type: string | null;
|
||||
remote_ip: string | null;
|
||||
protocol: string | null;
|
||||
country: string | null;
|
||||
confidence: number | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSecurity {
|
||||
encryption_audit: AgentEncryptionItem[];
|
||||
insecure_protocols: AgentInsecureProtocolItem[];
|
||||
unencrypted_passwords: AgentUnencryptedPwdItem[];
|
||||
ip_reputation: AgentIpReputationItem[];
|
||||
tor_detections: AgentTorItem[];
|
||||
vpn_detections: AgentVpnItem[];
|
||||
}
|
||||
|
||||
export interface AgentEventItem {
|
||||
event_id: string | null;
|
||||
event_type: string | null;
|
||||
severity: string | null;
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
description: string | null;
|
||||
event_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentMacBandwidthItem {
|
||||
mac_address: string;
|
||||
manufacturer: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AgentServerDiscoveryItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
server_type: string | null;
|
||||
hostname: string | null;
|
||||
port: number | null;
|
||||
protocol: string | null;
|
||||
os_label: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentDetails {
|
||||
agent_uuid: string;
|
||||
agent_label: string;
|
||||
summary: AgentSummary | null;
|
||||
devices: AgentDeviceItem[];
|
||||
flows: AgentFlowItem[];
|
||||
top_apps: AgentAppItem[];
|
||||
security: AgentSecurity | null;
|
||||
events: AgentEventItem[];
|
||||
mac_bandwidth: AgentMacBandwidthItem[];
|
||||
server_discovery: AgentServerDiscoveryItem[];
|
||||
}
|
||||
|
||||
export interface DeviceFlowItem {
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
protocol: string | null;
|
||||
app_label?: string | null;
|
||||
domain?: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
last_seen: string | null;
|
||||
}
|
||||
|
||||
export interface SecurityInfo {
|
||||
encrypted_pct: number | null;
|
||||
encrypted_bytes: number | null;
|
||||
unencrypted_bytes: number | null;
|
||||
risk_level: string | null;
|
||||
insecure_protocols: { protocol: string; risk: string }[];
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
device_label: string | null;
|
||||
reputation: string | null;
|
||||
rep_score: number | null;
|
||||
blacklisted: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
device_label: string | null;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
mac_address: string | null;
|
||||
is_new: number | null;
|
||||
}
|
||||
|
||||
export interface DeviceAppItem {
|
||||
label: string;
|
||||
sub_label: string | null;
|
||||
type: 'domain' | 'protocol' | 'port';
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count: number;
|
||||
}
|
||||
|
||||
export interface DeviceDomainItem {
|
||||
domain: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count: number;
|
||||
}
|
||||
|
||||
export interface DeviceEncryption {
|
||||
encrypted_pct: number | null;
|
||||
encrypted_bytes: number | null;
|
||||
unencrypted_bytes: number | null;
|
||||
total_bytes: number | null;
|
||||
risk_level: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceServerItem {
|
||||
server_type: string | null;
|
||||
hostname: string | null;
|
||||
port: number | null;
|
||||
protocol: string | null;
|
||||
os_label: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface DevicePwdItem {
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
protocol: string | null;
|
||||
username: string | null;
|
||||
severity: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceReputationItem {
|
||||
remote_ip: string | null;
|
||||
local_ip: string | null;
|
||||
reputation: string | null;
|
||||
score: number | null;
|
||||
country: string | null;
|
||||
app_label: string | null;
|
||||
blacklisted: boolean;
|
||||
download: number;
|
||||
upload: number;
|
||||
}
|
||||
|
||||
export interface DeviceVpnItem {
|
||||
vpn_type: string | null;
|
||||
remote_ip: string | null;
|
||||
protocol: string | null;
|
||||
country: string | null;
|
||||
confidence: number | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceEventItem {
|
||||
event_type: string | null;
|
||||
severity: string | null;
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
description: string | null;
|
||||
event_at: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceMacBandwidth {
|
||||
mac_address: string;
|
||||
manufacturer: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DeviceDetails {
|
||||
ip_address: string;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
last_seen: string | null;
|
||||
first_seen?: string | null;
|
||||
total_download: number;
|
||||
total_upload: number;
|
||||
agent_uuid?: string | null;
|
||||
agent_label?: string | null;
|
||||
flows: FlowStat[];
|
||||
apps: AppStat[];
|
||||
protocols: AppStat[];
|
||||
domains: AppStat[];
|
||||
destinations: AppStat[];
|
||||
threats: ThreatStat[];
|
||||
}
|
||||
|
||||
export interface AppAgentItem {
|
||||
agent_uuid: string | null;
|
||||
agent_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count?: number;
|
||||
}
|
||||
|
||||
export interface AppIpItem {
|
||||
ip_address: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count?: number;
|
||||
reputation?: string | null;
|
||||
blacklisted?: boolean;
|
||||
dst_ip?: string | null;
|
||||
domain?: string | null;
|
||||
last_seen?: string | null;
|
||||
}
|
||||
|
||||
export interface AppDetails {
|
||||
app_label: string;
|
||||
total_download: number;
|
||||
total_upload: number;
|
||||
data_source?: string;
|
||||
has_domain_breakdown?: boolean;
|
||||
agent_scorecard: AppAgentItem[];
|
||||
top_ips: AppIpItem[];
|
||||
}
|
||||
|
||||
export interface SecurityDevice {
|
||||
ip_address: string;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
encrypted_pct: number;
|
||||
unencrypted: number;
|
||||
encrypted: number;
|
||||
total: number;
|
||||
risk_level: 'Vulnerable' | 'Moderate' | 'Safe';
|
||||
has_insecure: boolean;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// src/lib/api-metadata-detail.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Frontend API hook for the BackOne Metadata row-detail panel.
|
||||
// Fetches real MongoDB breakdown data when a user clicks a table row.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export type MetadataDetailType =
|
||||
| 'sni_hostname'
|
||||
| 'ssl_server_cn'
|
||||
| 'quic_hostname'
|
||||
| 'netbios_hostname'
|
||||
| 'os_label'
|
||||
| 'dhcp_fingerprint'
|
||||
| 'http_useragent'
|
||||
| 'bittorrent_hash'
|
||||
| 'ssh_version'
|
||||
| 'mdns_hostname';
|
||||
|
||||
export interface MetadataDetailRow {
|
||||
// Domain-based (SNI, SSL CN, QUIC)
|
||||
src_ip?: string;
|
||||
device_label?: string;
|
||||
mac_address?: string;
|
||||
manufacturer?: string;
|
||||
os_label?: string;
|
||||
|
||||
// Device-based (NetBIOS, OS Discovery)
|
||||
ip_address?: string;
|
||||
device_type?: string;
|
||||
|
||||
// BitTorrent
|
||||
info_hash?: string;
|
||||
label?: string;
|
||||
|
||||
// SSH
|
||||
role?: string;
|
||||
|
||||
// Agent info
|
||||
agent_uuid?: string;
|
||||
timestamp?: string;
|
||||
last_seen?: string;
|
||||
|
||||
// Bandwidth (all types)
|
||||
download: number;
|
||||
upload: number;
|
||||
flows?: number;
|
||||
}
|
||||
|
||||
export interface MetadataDetailResult {
|
||||
ok: boolean;
|
||||
type: string;
|
||||
value: string;
|
||||
count: number;
|
||||
data: MetadataDetailRow[];
|
||||
}
|
||||
|
||||
export function useMetadataDetail(
|
||||
type: MetadataDetailType | null,
|
||||
value: string | null,
|
||||
timeRange: string = '1d'
|
||||
) {
|
||||
const [data, setData] = useState<MetadataDetailRow[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
if (!type || !value) { setData([]); return; }
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ type, value, timeRange });
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/dashboard/metadata-detail?${params}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const result: MetadataDetailResult = await response.json();
|
||||
if (result.ok) setData(result.data);
|
||||
else throw new Error(result.ok === false ? 'Server error' : 'Unknown error');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
setData([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [type, value, timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDetail();
|
||||
}, [fetchDetail]);
|
||||
|
||||
return { data, isLoading, error, refetch: fetchDetail };
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// src/lib/api-types.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Type and Interface definitions for the BackOne client-side API layer.
|
||||
// Split from api.ts to respect the 256-line threshold limit.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Common Response Wrapper
|
||||
export interface ApiResponse<T> {
|
||||
ok: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Data Types
|
||||
export interface FlowStat {
|
||||
id: number;
|
||||
fetched_at: string;
|
||||
flow_id: string;
|
||||
src_ip: string;
|
||||
src_mac: string | null;
|
||||
dst_ip: string;
|
||||
dst_port: number;
|
||||
protocol: string;
|
||||
app_label: string | null;
|
||||
domain: string | null;
|
||||
bytes_download: number;
|
||||
bytes_upload: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export interface CountryStat {
|
||||
id: number;
|
||||
country_code: string;
|
||||
country_name: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count: number;
|
||||
}
|
||||
|
||||
export interface DnsStat {
|
||||
id: number;
|
||||
domain: string;
|
||||
query_count: number;
|
||||
app_label: string | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
export interface EventStat {
|
||||
id: number;
|
||||
event_type: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
source_ip: string | null;
|
||||
mac_address: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface TlsVersionStat {
|
||||
id: number;
|
||||
tls_version: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TlsCipherStat {
|
||||
id: number;
|
||||
tls_cipher: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TlsSecurityStat {
|
||||
id: number;
|
||||
tls_security: string;
|
||||
color: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface IntelligenceStats {
|
||||
intel_crypto_mining: number;
|
||||
intel_device_discovery: number;
|
||||
intel_encryption_audit: number;
|
||||
intel_insecure_protocols: number;
|
||||
intel_ip_reputation: number;
|
||||
intel_server_discovery: number;
|
||||
intel_tor_detection: number;
|
||||
intel_unencrypted_passwords: number;
|
||||
intel_vpn_detection: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
total_devices: number;
|
||||
total_threats: number;
|
||||
total_events: number;
|
||||
last_fetch: string;
|
||||
bandwidth_down: number;
|
||||
bandwidth_up: number;
|
||||
active_flows: number;
|
||||
download_speed?: number;
|
||||
upload_speed?: number;
|
||||
flow_speed?: number;
|
||||
}
|
||||
|
||||
export interface TimelinePoint {
|
||||
fetched_at: string;
|
||||
total_download: number;
|
||||
total_upload: number;
|
||||
packet_drops?: number;
|
||||
peak_flow_rate?: number;
|
||||
total_flows?: number;
|
||||
download_speed?: number;
|
||||
upload_speed?: number;
|
||||
cpu_usage?: number;
|
||||
memory_usage?: number;
|
||||
queue_depth?: number;
|
||||
}
|
||||
|
||||
export interface AppStat {
|
||||
app_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
favicon?: string;
|
||||
category?: string;
|
||||
first_seen?: string;
|
||||
last_seen?: string;
|
||||
}
|
||||
|
||||
export interface ProtocolStat {
|
||||
protocol_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
}
|
||||
|
||||
export interface DeviceStat {
|
||||
id: number;
|
||||
ip_address: string;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
last_seen: string;
|
||||
agent_uuid?: string | null;
|
||||
is_gateway_routed?: number | null;
|
||||
}
|
||||
|
||||
export interface ThreatStat {
|
||||
id: number;
|
||||
threat_type: string | null;
|
||||
severity: string | null;
|
||||
ip_address: string | null;
|
||||
dst_ip: string | null;
|
||||
mac_address: string | null;
|
||||
app_label: string | null;
|
||||
domain: string | null;
|
||||
detected_at: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface AppCategoryStat {
|
||||
id: number;
|
||||
category_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SslSanStat {
|
||||
alt_name: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flows: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ContinentStat {
|
||||
id: number;
|
||||
continent_name: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// src/lib/api-with-context.ts
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* api-with-context.ts
|
||||
*
|
||||
* Re-exports all API hooks from api.ts with automatic injection of:
|
||||
* 1. `timeRange` — from TimeFilterContext (global time filter selector)
|
||||
* 2. Auto-refresh — every 5 minutes to stay in sync with proxy collection
|
||||
* 3. Client-side memory caching — data renders instantly on tab switches (no loading spinner)
|
||||
* while revalidation happens in the background.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTimeFilter } from '@/contexts/TimeFilterContext';
|
||||
import { fetcher } from './api';
|
||||
import { useCtxFetch, globalCache, REFRESH_INTERVAL_MS } from './api-context-core';
|
||||
import type {
|
||||
DashboardSummary, TimelinePoint, AppStat, ProtocolStat, DeviceStat, ThreatStat,
|
||||
AppCategoryStat, ContinentStat, FlowStat, CountryStat, DnsStat, EventStat,
|
||||
TlsVersionStat, TlsCipherStat, TlsSecurityStat, IntelligenceStats, SslSanStat,
|
||||
} from './api';
|
||||
|
||||
export type {
|
||||
DashboardSummary, TimelinePoint, AppStat, ProtocolStat, DeviceStat, ThreatStat,
|
||||
AppCategoryStat, ContinentStat, FlowStat, CountryStat, DnsStat, EventStat,
|
||||
TlsVersionStat, TlsCipherStat, TlsSecurityStat, IntelligenceStats, SslSanStat,
|
||||
};
|
||||
|
||||
export function useDashboardSummary() {
|
||||
const { timeRange } = useTimeFilter();
|
||||
const endpoint = '/summary';
|
||||
const cacheKey = `${endpoint}:${timeRange}`;
|
||||
|
||||
const [data, setData] = useState<DashboardSummary | null>(() => {
|
||||
return (globalCache[cacheKey] as DashboardSummary) ?? null;
|
||||
});
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(() => {
|
||||
return !globalCache[cacheKey];
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setTick(v => v + 1), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!globalCache[cacheKey]) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
fetcher<DashboardSummary>(endpoint, timeRange)
|
||||
.then(res => {
|
||||
globalCache[cacheKey] = res;
|
||||
setData(res);
|
||||
})
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeRange, tick]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTimeline(points: number = 60, agentUuid?: string) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
const endpoint = agentUuid
|
||||
? `/timeline?points=${points}&agent_uuid=${agentUuid}`
|
||||
: `/timeline?points=${points}`;
|
||||
const cacheKey = `${endpoint}:${timeRange}`;
|
||||
|
||||
const [data, setData] = useState<TimelinePoint[]>(() => {
|
||||
return (globalCache[cacheKey] as TimelinePoint[]) ?? [];
|
||||
});
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(() => {
|
||||
return !globalCache[cacheKey];
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setTick(v => v + 1), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!globalCache[cacheKey]) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
fetcher<TimelinePoint[]>(endpoint, timeRange)
|
||||
.then(res => {
|
||||
const val = Array.isArray(res) ? res.slice().reverse() : [];
|
||||
globalCache[cacheKey] = val;
|
||||
setData(val);
|
||||
})
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [points, agentUuid, timeRange, tick]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTopApps(limit: number = 10) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<AppStat[]>(`/apps?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useTopProtocols() {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<ProtocolStat[]>('/protocols', timeRange, []);
|
||||
}
|
||||
|
||||
export function useSslSans(limit: number = 50) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<SslSanStat[]>(`/ssl-subject-alt-names?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useDevices(limit: number = 0) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<DeviceStat[]>(`/devices?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useThreats(limit: number = 0) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<ThreatStat[]>(`/threats?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useAppCategories() {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<AppCategoryStat[]>('/app-categories', timeRange, []);
|
||||
}
|
||||
|
||||
export function useContinents() {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<ContinentStat[]>('/continents', timeRange, []);
|
||||
}
|
||||
|
||||
export function useFlows(limit: number = 0) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<FlowStat[]>(`/flows?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useCountries(limit: number = 0) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<CountryStat[]>(`/countries?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useDNS(limit: number = 0) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<DnsStat[]>(`/dns?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useEvents(limit: number = 0) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<EventStat[]>(`/events?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useTlsVersions(limit: number = 10) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<TlsVersionStat[]>(`/tls-versions?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useTlsCiphers(limit: number = 15) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<TlsCipherStat[]>(`/tls-ciphers?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useTlsSecurity(limit: number = 10) {
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<TlsSecurityStat[]>(`/tls-security?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
export function useIntelligenceStats() {
|
||||
const { timeRange } = useTimeFilter();
|
||||
const endpoint = '/intelligence/stats';
|
||||
const cacheKey = `${endpoint}:${timeRange}`;
|
||||
|
||||
const [data, setData] = useState<IntelligenceStats | null>(() => {
|
||||
return (globalCache[cacheKey] as IntelligenceStats) ?? null;
|
||||
});
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(() => {
|
||||
return !globalCache[cacheKey];
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setTick(v => v + 1), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!globalCache[cacheKey]) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
fetcher<IntelligenceStats>(endpoint, timeRange)
|
||||
.then(res => {
|
||||
globalCache[cacheKey] = res;
|
||||
setData(res);
|
||||
})
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeRange, tick]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
// Re-export utilities from api.ts
|
||||
export { fetcher, fetchAgentDetails, fetchDeviceDetails, fetchAppDetails, fetchSecurityDevices } from './api';
|
||||
export { useLookupApplications } from './api';
|
||||
+12
-849
@@ -1,849 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getViewAsHeaders } from './admin-api';
|
||||
|
||||
// Common Response Wrapper
|
||||
export interface ApiResponse<T> {
|
||||
ok: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// Data Types
|
||||
export interface FlowStat {
|
||||
id: number;
|
||||
fetched_at: string;
|
||||
flow_id: string;
|
||||
src_ip: string;
|
||||
src_mac: string | null;
|
||||
dst_ip: string;
|
||||
dst_port: number;
|
||||
protocol: string;
|
||||
app_label: string | null;
|
||||
domain: string | null;
|
||||
bytes_download: number;
|
||||
bytes_upload: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export interface CountryStat {
|
||||
id: number;
|
||||
country_code: string;
|
||||
country_name: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count: number;
|
||||
}
|
||||
|
||||
export interface DnsStat {
|
||||
id: number;
|
||||
domain: string;
|
||||
query_count: number;
|
||||
app_label: string | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
export interface EventStat {
|
||||
id: number;
|
||||
event_type: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
source_ip: string | null;
|
||||
mac_address: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface TlsVersionStat {
|
||||
id: number;
|
||||
tls_version: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TlsCipherStat {
|
||||
id: number;
|
||||
tls_cipher: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TlsSecurityStat {
|
||||
id: number;
|
||||
tls_security: string;
|
||||
color: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface IntelligenceStats {
|
||||
intel_crypto_mining: number;
|
||||
intel_device_discovery: number;
|
||||
intel_encryption_audit: number;
|
||||
intel_insecure_protocols: number;
|
||||
intel_ip_reputation: number;
|
||||
intel_server_discovery: number;
|
||||
intel_tor_detection: number;
|
||||
intel_unencrypted_passwords: number;
|
||||
intel_vpn_detection: number;
|
||||
}
|
||||
|
||||
export interface DashboardSummary {
|
||||
total_devices: number;
|
||||
total_threats: number;
|
||||
total_events: number;
|
||||
last_fetch: string;
|
||||
bandwidth_down: number;
|
||||
bandwidth_up: number;
|
||||
active_flows: number;
|
||||
download_speed?: number;
|
||||
upload_speed?: number;
|
||||
flow_speed?: number;
|
||||
}
|
||||
|
||||
export interface TimelinePoint {
|
||||
fetched_at: string;
|
||||
total_download: number;
|
||||
total_upload: number;
|
||||
}
|
||||
|
||||
export interface AppStat {
|
||||
app_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
favicon?: string;
|
||||
category?: string;
|
||||
first_seen?: string;
|
||||
last_seen?: string;
|
||||
}
|
||||
|
||||
export interface ProtocolStat {
|
||||
protocol_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
}
|
||||
|
||||
export interface DeviceStat {
|
||||
id: number;
|
||||
ip_address: string;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
last_seen: string;
|
||||
is_gateway_routed?: number | null;
|
||||
}
|
||||
|
||||
export interface ThreatStat {
|
||||
id: number;
|
||||
threat_type: string | null;
|
||||
severity: string | null;
|
||||
ip_address: string | null;
|
||||
dst_ip: string | null;
|
||||
mac_address: string | null;
|
||||
app_label: string | null;
|
||||
domain: string | null;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AppCategoryStat {
|
||||
id: number;
|
||||
category_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ContinentStat {
|
||||
id: number;
|
||||
continent_name: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Fetcher Function
|
||||
export async function fetcher<T>(endpoint: string): Promise<T> {
|
||||
const url = `/api/dashboard${endpoint}`;
|
||||
// Tambahkan X-View-As-Agent header jika admin dalam mode view-as
|
||||
const viewAsHeaders = getViewAsHeaders();
|
||||
const res = await fetch(url, { headers: viewAsHeaders });
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.ok) {
|
||||
throw new Error(json.message || 'API responded with ok: false');
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// Custom Hooks for React Components
|
||||
export function useDashboardSummary() {
|
||||
const [data, setData] = useState<DashboardSummary | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<DashboardSummary>('/summary')
|
||||
.then(setData)
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTimeline(points: number = 60) {
|
||||
const [data, setData] = useState<TimelinePoint[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<TimelinePoint[]>(`/timeline?points=${points}`)
|
||||
.then((res) => {
|
||||
// Reverse array if backend sends newest first, so oldest is first for the chart
|
||||
setData(Array.isArray(res) ? res.slice().reverse() : []);
|
||||
})
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [points]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTopApps(limit: number = 10) {
|
||||
const [data, setData] = useState<AppStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<AppStat[]>(`/apps?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTopProtocols() {
|
||||
const [data, setData] = useState<ProtocolStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<ProtocolStat[]>('/protocols')
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useDevices(limit: number = 50) {
|
||||
const [data, setData] = useState<DeviceStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<DeviceStat[]>(`/devices?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useThreats(limit: number = 20) {
|
||||
const [data, setData] = useState<ThreatStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<ThreatStat[]>(`/threats?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useAppCategories() {
|
||||
const [data, setData] = useState<AppCategoryStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<AppCategoryStat[]>('/app-categories')
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useContinents() {
|
||||
const [data, setData] = useState<ContinentStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<ContinentStat[]>('/continents')
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useFlows(limit: number = 50) {
|
||||
const [data, setData] = useState<FlowStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<FlowStat[]>(`/flows?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useCountries(limit: number = 50) {
|
||||
const [data, setData] = useState<CountryStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<CountryStat[]>(`/countries?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useDNS(limit: number = 50) {
|
||||
const [data, setData] = useState<DnsStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<DnsStat[]>(`/dns?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useEvents(limit: number = 50) {
|
||||
const [data, setData] = useState<EventStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<EventStat[]>(`/events?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTlsVersions(limit: number = 10) {
|
||||
const [data, setData] = useState<TlsVersionStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<TlsVersionStat[]>(`/tls-versions?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTlsCiphers(limit: number = 15) {
|
||||
const [data, setData] = useState<TlsCipherStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<TlsCipherStat[]>(`/tls-ciphers?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTlsSecurity(limit: number = 10) {
|
||||
const [data, setData] = useState<TlsSecurityStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<TlsSecurityStat[]>(`/tls-security?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useIntelligenceStats() {
|
||||
const [data, setData] = useState<IntelligenceStats | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<IntelligenceStats>('/intelligence/stats')
|
||||
.then(setData)
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
// ─── LOOKUP APPLICATIONS ──────────────────────────────────────────────────────
|
||||
export interface LookupApp {
|
||||
id: number;
|
||||
tag: string;
|
||||
label: string;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string;
|
||||
favicon: string;
|
||||
icon: string;
|
||||
logo: string;
|
||||
application_category: { id: number; tag: string; label: string };
|
||||
}
|
||||
|
||||
export interface LookupApplicationsResponse {
|
||||
applications: LookupApp[];
|
||||
pagination: {
|
||||
total_records: number;
|
||||
total_pages: number;
|
||||
current_page: number;
|
||||
start: number;
|
||||
length: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function useLookupApplications(page: number = 1, limit: number = 25, search: string = '') {
|
||||
const [data, setData] = useState<LookupApplicationsResponse | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetcher<LookupApplicationsResponse>(`/lookup/applications?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`)
|
||||
.then(setData)
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [page, limit, search]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
// ─── INTERACTIVE DETAIL TYPES ──────────────────────────────────────────────────
|
||||
|
||||
export interface AgentDeviceItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
last_seen: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
encrypted_pct: number | null;
|
||||
risk_level: string | null;
|
||||
has_insecure: boolean;
|
||||
}
|
||||
|
||||
export interface AgentFlowItem {
|
||||
flow_id?: string | null;
|
||||
src_ip: string | null;
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
protocol: string | null;
|
||||
app_label?: string | null;
|
||||
domain?: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
last_seen: string | null;
|
||||
}
|
||||
|
||||
export interface AgentAppItem {
|
||||
app_id: number | null;
|
||||
app_label: string;
|
||||
category: string | null;
|
||||
favicon: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
}
|
||||
|
||||
export interface AgentSummary {
|
||||
total_devices: number;
|
||||
active_flows: number;
|
||||
bandwidth_down: number;
|
||||
bandwidth_up: number;
|
||||
}
|
||||
|
||||
export interface AgentEncryptionItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
encrypted_pct: number | null;
|
||||
unencrypted: number | null;
|
||||
encrypted: number | null;
|
||||
total: number | null;
|
||||
risk_level: string | null;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentInsecureProtocolItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
protocol: string | null;
|
||||
risk: string | null;
|
||||
app_label: string | null;
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentUnencryptedPwdItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
protocol: string | null;
|
||||
username: string | null;
|
||||
severity: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentIpReputationItem {
|
||||
ip_address: string | null;
|
||||
local_ip: string | null;
|
||||
mac_address: string | null;
|
||||
reputation: string | null;
|
||||
score: number | null;
|
||||
country: string | null;
|
||||
app_label: string | null;
|
||||
blacklisted: number | boolean;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentTorItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
exit_node: string | null;
|
||||
circuit_id: string | null;
|
||||
country: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentVpnItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
vpn_type: string | null;
|
||||
remote_ip: string | null;
|
||||
protocol: string | null;
|
||||
country: string | null;
|
||||
confidence: number | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentSecurity {
|
||||
encryption_audit: AgentEncryptionItem[];
|
||||
insecure_protocols: AgentInsecureProtocolItem[];
|
||||
unencrypted_passwords: AgentUnencryptedPwdItem[];
|
||||
ip_reputation: AgentIpReputationItem[];
|
||||
tor_detections: AgentTorItem[];
|
||||
vpn_detections: AgentVpnItem[];
|
||||
}
|
||||
|
||||
export interface AgentEventItem {
|
||||
event_id: string | null;
|
||||
event_type: string | null;
|
||||
severity: string | null;
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
description: string | null;
|
||||
event_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentMacBandwidthItem {
|
||||
mac_address: string;
|
||||
manufacturer: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AgentServerDiscoveryItem {
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
server_type: string | null;
|
||||
hostname: string | null;
|
||||
port: number | null;
|
||||
protocol: string | null;
|
||||
os_label: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface AgentDetails {
|
||||
agent_uuid: string;
|
||||
agent_label: string;
|
||||
summary: AgentSummary | null;
|
||||
devices: AgentDeviceItem[];
|
||||
flows: AgentFlowItem[];
|
||||
top_apps: AgentAppItem[];
|
||||
security: AgentSecurity | null;
|
||||
events: AgentEventItem[];
|
||||
mac_bandwidth: AgentMacBandwidthItem[];
|
||||
server_discovery: AgentServerDiscoveryItem[];
|
||||
}
|
||||
|
||||
export interface DeviceFlowItem {
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
protocol: string | null;
|
||||
app_label?: string | null;
|
||||
domain?: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
last_seen: string | null;
|
||||
}
|
||||
|
||||
export interface SecurityInfo {
|
||||
encrypted_pct: number | null;
|
||||
encrypted_bytes: number | null;
|
||||
unencrypted_bytes: number | null;
|
||||
risk_level: string | null;
|
||||
insecure_protocols: { protocol: string; risk: string }[];
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
device_label: string | null;
|
||||
reputation: string | null;
|
||||
rep_score: number | null;
|
||||
blacklisted: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceInfo {
|
||||
device_label: string | null;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
mac_address: string | null;
|
||||
is_new: number | null;
|
||||
}
|
||||
|
||||
export interface DeviceAppItem {
|
||||
label: string; // domain name OR protocol name
|
||||
sub_label: string | null; // protocol when label is domain; null otherwise
|
||||
type: 'domain' | 'protocol' | 'port';
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count: number;
|
||||
}
|
||||
|
||||
export interface DeviceDomainItem {
|
||||
domain: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count: number;
|
||||
}
|
||||
|
||||
export interface DeviceEncryption {
|
||||
encrypted_pct: number | null;
|
||||
encrypted_bytes: number | null;
|
||||
unencrypted_bytes: number | null;
|
||||
total_bytes: number | null;
|
||||
risk_level: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceServerItem {
|
||||
server_type: string | null;
|
||||
hostname: string | null;
|
||||
port: number | null;
|
||||
protocol: string | null;
|
||||
os_label: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface DevicePwdItem {
|
||||
dst_ip: string | null;
|
||||
dst_port: number | null;
|
||||
protocol: string | null;
|
||||
username: string | null;
|
||||
severity: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceReputationItem {
|
||||
remote_ip: string | null;
|
||||
local_ip: string | null;
|
||||
reputation: string | null;
|
||||
score: number | null;
|
||||
country: string | null;
|
||||
app_label: string | null;
|
||||
blacklisted: boolean;
|
||||
download: number;
|
||||
upload: number;
|
||||
}
|
||||
|
||||
export interface DeviceVpnItem {
|
||||
vpn_type: string | null;
|
||||
remote_ip: string | null;
|
||||
protocol: string | null;
|
||||
country: string | null;
|
||||
confidence: number | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
detected_at: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceEventItem {
|
||||
event_type: string | null;
|
||||
severity: string | null;
|
||||
ip_address: string | null;
|
||||
mac_address: string | null;
|
||||
description: string | null;
|
||||
event_at: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceMacBandwidth {
|
||||
mac_address: string;
|
||||
manufacturer: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DeviceDetails {
|
||||
ip_address: string;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
last_seen: string | null;
|
||||
total_download: number;
|
||||
total_upload: number;
|
||||
flows: FlowStat[];
|
||||
apps: AppStat[];
|
||||
protocols: AppStat[];
|
||||
domains: AppStat[];
|
||||
destinations: AppStat[];
|
||||
threats: ThreatStat[];
|
||||
}
|
||||
|
||||
|
||||
export interface AppAgentItem {
|
||||
agent_uuid: string | null;
|
||||
agent_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count?: number;
|
||||
}
|
||||
|
||||
export interface AppIpItem {
|
||||
ip_address: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
flow_count?: number;
|
||||
reputation?: string | null;
|
||||
blacklisted?: boolean;
|
||||
dst_ip?: string | null;
|
||||
domain?: string | null;
|
||||
last_seen?: string | null;
|
||||
}
|
||||
|
||||
export interface AppDetails {
|
||||
app_label: string;
|
||||
total_download: number;
|
||||
total_upload: number;
|
||||
data_source?: string;
|
||||
has_domain_breakdown?: boolean;
|
||||
agent_scorecard: AppAgentItem[];
|
||||
top_ips: AppIpItem[];
|
||||
}
|
||||
|
||||
export interface SecurityDevice {
|
||||
ip_address: string;
|
||||
mac_address: string | null;
|
||||
device_label: string | null;
|
||||
encrypted_pct: number;
|
||||
unencrypted: number;
|
||||
encrypted: number;
|
||||
total: number;
|
||||
risk_level: 'Rawan' | 'Sedang' | 'Aman';
|
||||
has_insecure: boolean;
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
}
|
||||
|
||||
// ─── DETAIL FETCH FUNCTIONS (one-shot, no polling) ───────────────────────────
|
||||
|
||||
export async function fetchAgentDetails(uuid: string): Promise<AgentDetails> {
|
||||
return fetcher<AgentDetails>(`/agent-details?uuid=${encodeURIComponent(uuid)}`);
|
||||
}
|
||||
|
||||
export async function fetchDeviceDetails(ip: string, timeRange: string = 'last1hour'): Promise<DeviceDetails> {
|
||||
return fetcher<DeviceDetails>(`/device-details?ip=${encodeURIComponent(ip)}&timeRange=${timeRange}`);
|
||||
}
|
||||
|
||||
export async function fetchAppDetails(label: string): Promise<AppDetails> {
|
||||
return fetcher<AppDetails>(`/app-details?label=${encodeURIComponent(label)}`);
|
||||
}
|
||||
|
||||
export async function fetchSecurityDevices(): Promise<SecurityDevice[]> {
|
||||
return fetcher<SecurityDevice[]>('/security-devices');
|
||||
}
|
||||
|
||||
// src/lib/api.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BackOne Dashboard Client API Layer (Unified exports orchestrator).
|
||||
// Satisfies the 256-line threshold limit by forwarding modular chunks.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export * from "./api-types";
|
||||
export * from "./api-detail-types";
|
||||
export * from "./api/core";
|
||||
export * from "./api/traffic";
|
||||
export * from "./api/security";
|
||||
export * from "./api/details";
|
||||
@@ -0,0 +1,97 @@
|
||||
// src/lib/api/core.ts
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getViewAsHeaders } from '../admin-api';
|
||||
import { DashboardSummary, TimelinePoint } from '../api-types';
|
||||
|
||||
export async function fetcher<T>(endpoint: string, timeRange?: string): Promise<T> {
|
||||
let url = `/api/dashboard${endpoint}`;
|
||||
if (timeRange) {
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
url += sep + 'timeRange=' + timeRange;
|
||||
}
|
||||
const viewAsHeaders = getViewAsHeaders();
|
||||
const res = await fetch(url, { headers: viewAsHeaders });
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.ok) {
|
||||
throw new Error(json.message || 'API responded with ok: false');
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export function useDashboardSummary(timeRange?: string) {
|
||||
const [data, setData] = useState<DashboardSummary | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<DashboardSummary>('/summary', timeRange)
|
||||
.then(setData)
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTimeline(points: number = 60, timeRange?: string) {
|
||||
const [data, setData] = useState<TimelinePoint[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<TimelinePoint[]>(`/timeline?points=${points}`, timeRange)
|
||||
.then((res) => {
|
||||
setData(Array.isArray(res) ? res.slice().reverse() : []);
|
||||
})
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [points, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export interface LookupApp {
|
||||
id: number;
|
||||
tag: string;
|
||||
label: string;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string;
|
||||
favicon: string;
|
||||
icon: string;
|
||||
logo: string;
|
||||
application_category: { id: number; tag: string; label: string };
|
||||
}
|
||||
|
||||
export interface LookupApplicationsResponse {
|
||||
applications: LookupApp[];
|
||||
pagination: {
|
||||
total_records: number;
|
||||
total_pages: number;
|
||||
current_page: number;
|
||||
start: number;
|
||||
length: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function useLookupApplications(page: number = 1, limit: number = 25, search: string = '') {
|
||||
const [data, setData] = useState<LookupApplicationsResponse | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetcher<LookupApplicationsResponse>(`/lookup/applications?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`)
|
||||
.then(setData)
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [page, limit, search]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// src/lib/api/details.ts
|
||||
"use client";
|
||||
|
||||
import { fetcher } from "./core";
|
||||
import { AgentDetails, DeviceDetails, AppDetails, SecurityDevice } from "../api-detail-types";
|
||||
|
||||
export async function fetchAgentDetails(uuid: string): Promise<AgentDetails> {
|
||||
return fetcher<AgentDetails>(`/agent-details?uuid=${encodeURIComponent(uuid)}`);
|
||||
}
|
||||
|
||||
export async function fetchDeviceDetails(ip: string, timeRange: string = 'all', mac: string = '', agentUuid: string = ''): Promise<DeviceDetails> {
|
||||
const query = [];
|
||||
if (ip) query.push(`ip=${encodeURIComponent(ip)}`);
|
||||
if (mac) query.push(`mac=${encodeURIComponent(mac)}`);
|
||||
if (agentUuid) query.push(`agent_uuid=${encodeURIComponent(agentUuid)}`);
|
||||
query.push(`timeRange=${timeRange}`);
|
||||
return fetcher<DeviceDetails>(`/device-details?${query.join('&')}`);
|
||||
}
|
||||
|
||||
export async function fetchAppDetails(label: string, timeRange: string = '1h', agentUuid?: string | null): Promise<AppDetails> {
|
||||
let url = `/app-details?label=${encodeURIComponent(label)}&timeRange=${timeRange}`;
|
||||
if (agentUuid) {
|
||||
url += `&agent_uuid=${encodeURIComponent(agentUuid)}`;
|
||||
}
|
||||
return fetcher<AppDetails>(url);
|
||||
}
|
||||
|
||||
export async function fetchSecurityDevices(): Promise<SecurityDevice[]> {
|
||||
return fetcher<SecurityDevice[]>('/security-devices');
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// src/lib/api/security.ts
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetcher } from './core';
|
||||
import { ThreatStat, EventStat, TlsVersionStat, TlsCipherStat, TlsSecurityStat, IntelligenceStats } from '../api-types';
|
||||
|
||||
export function useThreats(limit: number = 20, timeRange?: string) {
|
||||
const [data, setData] = useState<ThreatStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<ThreatStat[]>(`/threats?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useEvents(limit: number = 0, timeRange?: string) {
|
||||
const [data, setData] = useState<EventStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<EventStat[]>(`/events?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTlsVersions(limit: number = 10, timeRange?: string) {
|
||||
const [data, setData] = useState<TlsVersionStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<TlsVersionStat[]>(`/tls-versions?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTlsCiphers(limit: number = 15, timeRange?: string) {
|
||||
const [data, setData] = useState<TlsCipherStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<TlsCipherStat[]>(`/tls-ciphers?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTlsSecurity(limit: number = 10, timeRange?: string) {
|
||||
const [data, setData] = useState<TlsSecurityStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<TlsSecurityStat[]>(`/tls-security?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useIntelligenceStats(timeRange?: string) {
|
||||
const [data, setData] = useState<IntelligenceStats | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<IntelligenceStats>('/intelligence/stats', timeRange)
|
||||
.then(setData)
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// src/lib/api/traffic.ts
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetcher } from './core';
|
||||
import { AppStat, ProtocolStat, DeviceStat, AppCategoryStat, ContinentStat, FlowStat, CountryStat, DnsStat } from '../api-types';
|
||||
|
||||
export function useTopApps(limit: number = 10, timeRange?: string) {
|
||||
const [data, setData] = useState<AppStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<AppStat[]>(`/apps?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useTopProtocols(timeRange?: string) {
|
||||
const [data, setData] = useState<ProtocolStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<ProtocolStat[]>('/protocols', timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useDevices(limit: number = 50, timeRange?: string) {
|
||||
const [data, setData] = useState<DeviceStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<DeviceStat[]>(`/devices?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useAppCategories(timeRange?: string) {
|
||||
const [data, setData] = useState<AppCategoryStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<AppCategoryStat[]>('/app-categories', timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useContinents(timeRange?: string) {
|
||||
const [data, setData] = useState<ContinentStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<ContinentStat[]>('/continents', timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useFlows(limit: number = 0, timeRange?: string) {
|
||||
const [data, setData] = useState<FlowStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<FlowStat[]>(`/flows?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useCountries(limit: number = 0, timeRange?: string) {
|
||||
const [data, setData] = useState<CountryStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<CountryStat[]>(`/countries?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useDNS(limit: number = 0, timeRange?: string) {
|
||||
const [data, setData] = useState<DnsStat[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<DnsStat[]>(`/dns?limit=${limit}`, timeRange)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [limit, timeRange]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Latitude and Longitude coordinates (Centroids) for all ISO 3166-1 alpha-2 countries
|
||||
// Format: [longitude, latitude] to match the geoUtils format.
|
||||
export const WORLD_COUNTRY_COORDS: Record<string, [number, number]> = {
|
||||
"AD": [1.5218, 42.5063], "AE": [54.3074, 23.4241], "AF": [67.7099, 33.9391], "AG": [-61.7964, 17.0608], "AI": [-63.0686, 18.2206],
|
||||
"AL": [20.1683, 41.1533], "AM": [45.0382, 40.0691], "AO": [17.8739, -11.2027], "AQ": [0.0, -75.2509], "AR": [-63.6167, -38.4161],
|
||||
"AS": [-170.1322, -14.271], "AT": [14.5501, 47.5162], "AU": [133.7751, -25.2744], "AW": [-69.9683, 12.5211], "AX": [19.9156, 60.1785],
|
||||
"AZ": [47.5769, 40.1431], "BA": [17.6791, 43.9159], "BB": [-59.5432, 13.1939], "BD": [90.3563, 23.685], "BE": [4.4699, 50.5039],
|
||||
"BF": [-1.5616, 12.2383], "BG": [25.4858, 42.7339], "BH": [50.5577, 25.9304], "BI": [29.9189, -3.3731], "BJ": [2.3158, 9.3077],
|
||||
"BL": [-62.8333, 17.9], "BM": [-64.7505, 32.3213], "BN": [114.7277, 4.5353], "BO": [-63.5887, -16.2902], "BQ": [-68.2385, 12.1784],
|
||||
"BR": [-51.9253, -14.235], "BS": [-77.3963, 25.0343], "BT": [90.4336, 27.5142], "BV": [3.4, -54.4333], "BW": [24.6849, -22.3285],
|
||||
"BY": [27.9534, 53.7098], "BZ": [-88.4976, 17.1899], "CA": [-106.3468, 56.1304], "CC": [96.87, -12.1642], "CD": [21.7587, -4.0383],
|
||||
"CF": [20.9394, 6.6111], "CG": [15.8277, -0.228], "CH": [8.2275, 46.8182], "CI": [-5.5471, 7.54], "CK": [-159.7777, -21.2367],
|
||||
"CL": [-71.543, -35.6751], "CM": [12.3547, 7.3697], "CN": [104.1954, 35.8617], "CO": [-72.9301, 4.5709], "CR": [-83.7534, 9.7489],
|
||||
"CU": [-77.7812, 21.5218], "CV": [-23.0418, 16.0021], "CW": [-68.99, 12.1696], "CX": [105.6904, -10.4475], "CY": [33.4299, 35.1264],
|
||||
"CZ": [15.473, 49.8175], "DE": [10.4515, 51.1657], "DJ": [42.5903, 11.8251], "DK": [9.5018, 56.2639], "DM": [-61.371, 15.415],
|
||||
"DO": [-70.1627, 18.7357], "DZ": [1.6596, 28.0339], "EC": [-78.1834, -1.8312], "EE": [25.0136, 58.5953], "EG": [30.8025, 26.8206],
|
||||
"EH": [-12.8858, 24.2155], "ER": [39.7823, 15.1794], "ES": [-3.7492, 40.4637], "ET": [40.4897, 9.145], "FI": [25.7482, 61.9241],
|
||||
"FJ": [178.065, -17.7134], "FK": [-59.5236, -51.7963], "FM": [158.215, 6.9248], "FO": [-6.9118, 62.0079], "FR": [2.2137, 46.2276],
|
||||
"GA": [11.6094, -0.8037], "GB": [-3.436, 55.3781], "GD": [-61.679, 12.1165], "GE": [43.3569, 42.3154], "GF": [-53.1258, 3.9339],
|
||||
"GG": [-2.5853, 49.4657], "GH": [-1.0232, 7.9465], "GI": [-5.3536, 36.1408], "GL": [-42.6043, 71.7069], "GM": [-15.3101, 13.4432],
|
||||
"GN": [-9.6966, 9.9456], "GP": [-61.551, 16.996], "GQ": [10.2679, 1.6508], "GR": [21.8243, 39.0742], "GS": [-36.5879, -54.4296],
|
||||
"GT": [-90.2308, 15.7835], "GU": [144.7937, 13.4443], "GW": [-15.1804, 11.8037], "GY": [-58.9302, 4.8604], "HK": [114.1095, 22.3964],
|
||||
"HM": [73.5042, -53.0818], "HN": [-86.2419, 15.1999], "HR": [15.2000, 45.1000], "HT": [-72.2852, 18.9712], "HU": [19.5033, 47.1625],
|
||||
"ID": [113.9213, -0.7893], "IE": [-8.2439, 53.4129], "IL": [34.8516, 31.0461], "IM": [-4.5481, 54.2361], "IN": [78.9629, 20.5937],
|
||||
"IO": [71.8765, -6.3431], "IQ": [43.6793, 33.2232], "IR": [53.688, 32.4279], "IS": [-19.0208, 64.9631], "IT": [12.5674, 41.8719],
|
||||
"JE": [-2.1312, 49.2138], "JM": [-77.2975, 18.1096], "JO": [36.2384, 30.5852], "JP": [138.2529, 36.2048], "KE": [37.9062, -0.0236],
|
||||
"KG": [74.7661, 41.2044], "KH": [104.991, 12.5657], "KI": [173.0489, 1.8369], "KM": [43.3333, -11.8333], "KN": [-62.783, 17.3578],
|
||||
"KP": [127.5101, 40.3399], "KR": [127.7669, 35.9078], "KW": [47.4818, 29.3117], "KY": [-80.6665, 19.5135], "KZ": [66.9237, 48.0196],
|
||||
"LA": [102.4955, 19.8563], "LB": [35.8623, 33.8547], "LC": [-60.9789, 13.9094], "LI": [9.5554, 47.166], "LK": [80.7718, 7.8731],
|
||||
"LR": [-9.4295, 6.4281], "LS": [28.2336, -29.6035], "LT": [23.8813, 55.1694], "LU": [6.1296, 49.8153], "LV": [24.6032, 56.8796],
|
||||
"LY": [17.2283, 26.3351], "MA": [-8.2245, 31.7917], "MC": [7.4128, 43.7384], "MD": [28.3699, 47.4116], "ME": [19.3744, 42.7087],
|
||||
"MF": [-63.05, 18.0667], "MG": [46.8691, -18.7669], "MH": [171.1845, 7.1315], "MK": [21.7453, 41.6086], "ML": [-3.9962, 17.5707],
|
||||
"MM": [95.956, 21.9162], "MN": [103.8467, 46.8625], "MO": [113.5439, 22.1987], "MP": [145.6739, 15.0979], "MQ": [-61.0242, 14.6415],
|
||||
"MR": [-10.9408, 21.0079], "MS": [-62.1873, 16.7425], "MT": [14.3754, 35.9375], "MU": [57.5522, -20.3484], "MV": [73.5361, 3.2028],
|
||||
"MW": [34.3015, -13.2543], "MX": [-102.5528, 23.6345], "MY": [101.9758, 4.2105], "MZ": [35.5296, -18.6657], "NA": [18.4904, -22.9576],
|
||||
"NC": [165.618, -20.9043], "NE": [8.0817, 17.6078], "NF": [167.9547, -29.0408], "NG": [8.6753, 9.082], "NI": [-85.2072, 12.8654],
|
||||
"NL": [5.2913, 52.1326], "NO": [8.4689, 60.472], "NP": [84.124, 28.3949], "NR": [166.9315, -0.5228], "NU": [-169.8672, -19.0544],
|
||||
"NZ": [174.886, -40.9006], "OM": [55.9233, 21.5126], "PA": [-80.7821, 8.538], "PE": [-75.0152, -9.1899], "PF": [-144.305, -17.6797],
|
||||
"PG": [143.9555, -6.315], "PH": [121.774, 12.8797], "PK": [69.3451, 30.3753], "PL": [19.1451, 51.9194], "PM": [-56.2711, 46.9419],
|
||||
"PN": [-127.4393, -24.7036], "PR": [-66.5901, 18.2208], "PS": [35.2332, 31.9522], "PT": [-8.2245, 39.3999], "PW": [134.5825, 7.515],
|
||||
"PY": [-58.4438, -23.4425], "QA": [51.1839, 25.3548], "RE": [55.5364, -21.1151], "RO": [24.9668, 45.9432], "RS": [21.0059, 44.0165],
|
||||
"RU": [105.3188, 61.524], "RW": [29.8739, -1.9403], "SA": [45.0792, 23.8859], "SB": [160.1562, -9.6457], "SC": [55.492, -4.6796],
|
||||
"SD": [30.2176, 12.8628], "SE": [18.6435, 60.1282], "SG": [103.8198, 1.3521], "SH": [-5.7089, -15.965], "SI": [14.9955, 46.1512],
|
||||
"SJ": [23.6703, 77.5536], "SK": [19.699, 48.669], "SL": [-11.7799, 8.4606], "SM": [12.4578, 43.9424], "SN": [-14.4524, 14.4974],
|
||||
"SO": [46.1996, 5.1521], "SR": [-56.0278, 3.9193], "SS": [31.307, 6.877], "ST": [6.6131, 0.1864], "SV": [-88.8965, 13.7942],
|
||||
"SX": [-63.0489, 18.0425], "SY": [38.9968, 34.8021], "SZ": [31.4659, -26.5225], "TC": [-71.7979, 21.694], "TD": [18.7322, 15.4542],
|
||||
"TF": [69.3485, -49.2803], "TG": [0.8248, 8.6195], "TH": [100.9925, 15.87], "TJ": [71.2761, 38.861], "TK": [-171.855, -9.2002],
|
||||
"TL": [125.7275, -8.8742], "TM": [59.5563, 38.9697], "TN": [9.5375, 33.8869], "TO": [-175.1982, -21.1789], "TR": [35.2433, 38.9637],
|
||||
"TT": [-61.2225, 10.6918], "TV": [177.6493, -7.1095], "TW": [120.9605, 23.6978], "TZ": [34.8888, -6.369], "UA": [31.1656, 48.3794],
|
||||
"UG": [32.2903, 1.3733], "UM": [-160.0, 0.0], "US": [-95.7129, 37.0902], "UY": [-55.7658, -32.5228], "UZ": [64.5853, 41.3775],
|
||||
"VA": [12.4534, 41.9029], "VC": [-61.2872, 12.9843], "VE": [-66.5897, 6.4238], "VG": [-64.6399, 18.4207], "VI": [-64.8963, 18.3358],
|
||||
"VN": [108.2772, 14.0583], "VU": [166.9592, -15.3767], "WF": [-176.2089, -13.7687], "WS": [-172.1046, -13.759], "YE": [48.5164, 15.5527],
|
||||
"YT": [45.1662, -12.8275], "ZA": [25.0471, -30.5595], "ZM": [27.8493, -13.1339], "ZW": [29.1549, -19.0154]
|
||||
};
|
||||
+6
-35
@@ -1,3 +1,5 @@
|
||||
import { WORLD_COUNTRY_COORDS } from "./countryCoordinates";
|
||||
|
||||
export interface GeoLocation {
|
||||
lat: number;
|
||||
lng: number;
|
||||
@@ -5,38 +7,7 @@ export interface GeoLocation {
|
||||
}
|
||||
|
||||
// ─── 1. GLOBAL DESTINATION COORDINATES (By Country Code) ────────────────
|
||||
export const COUNTRY_COORDS: Record<string, [number, number]> = {
|
||||
"ID": [113.9213, -0.7893],
|
||||
"US": [-95.7129, 37.0902],
|
||||
"HK": [114.1095, 22.3964],
|
||||
"SG": [103.8198, 1.3521],
|
||||
"JP": [138.2529, 36.2048],
|
||||
"SC": [55.4920, -4.6796],
|
||||
"GB": [-3.4360, 55.3781],
|
||||
"IT": [12.5674, 41.8719],
|
||||
"CN": [104.1954, 35.8617],
|
||||
"KR": [127.7669, 35.9078],
|
||||
"CA": [-106.3468, 56.1304],
|
||||
"LU": [6.1296, 49.8153],
|
||||
"NL": [5.2913, 52.1326],
|
||||
"TR": [35.2433, 38.9637],
|
||||
"UA": [31.1656, 48.3794],
|
||||
"RU": [105.3188, 61.5240],
|
||||
"HU": [19.5033, 47.1625],
|
||||
"BD": [90.3563, 23.6850],
|
||||
"AU": [133.7751, -25.2744],
|
||||
"IN": [78.9629, 20.5937],
|
||||
"DE": [10.4515, 51.1657],
|
||||
"FR": [2.2137, 46.2276],
|
||||
"ES": [-3.7492, 40.4637],
|
||||
"BR": [-51.9253, -14.2350],
|
||||
"MY": [101.9758, 4.2105],
|
||||
"TH": [100.9925, 15.8700],
|
||||
"VN": [108.2772, 14.0583],
|
||||
"PH": [121.7740, 12.8797],
|
||||
"TW": [120.9605, 23.6978],
|
||||
"GR": [21.8243, 39.0742],
|
||||
};
|
||||
export const COUNTRY_COORDS: Record<string, [number, number]> = WORLD_COUNTRY_COORDS;
|
||||
|
||||
// ─── 2. MANUAL CONFIGURATION (For Local IPs, Agent UUIDs, or City Names) ───
|
||||
// Users can edit this section to map their internal locations accurately.
|
||||
@@ -87,7 +58,7 @@ export const COUNTRY_CAPITALS: Record<string, string> = {
|
||||
"DE": "Berlin",
|
||||
"FR": "Paris",
|
||||
"ES": "Madrid",
|
||||
"BR": "Brasília",
|
||||
"BR": "Brasilia",
|
||||
"MY": "Kuala Lumpur",
|
||||
"TH": "Bangkok",
|
||||
"VN": "Hanoi",
|
||||
@@ -118,13 +89,13 @@ export function getDestinationLocation(countryCode: string, countryName?: string
|
||||
const coords = COUNTRY_COORDS[countryCode];
|
||||
if (!coords) return null;
|
||||
|
||||
const cityName = COUNTRY_CAPITALS[countryCode] || "Unknown City";
|
||||
const cityName = COUNTRY_CAPITALS[countryCode];
|
||||
const cName = countryName || countryCode;
|
||||
|
||||
// Note: COUNTRY_COORDS uses [lng, lat] format
|
||||
return {
|
||||
lat: coords[1],
|
||||
lng: coords[0],
|
||||
label: `${cityName}, ${cName}`
|
||||
label: cityName ? `${cityName}, ${cName}` : cName
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* usePolling.ts
|
||||
*
|
||||
* Utility hook for triggering periodic re-fetches in the BackOne dashboard.
|
||||
* Used to achieve "realtime-like" updates without WebSockets.
|
||||
*
|
||||
* Default interval: 5 minutes (matching proxy collection schedule)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Returns a `refreshKey` that increments every `intervalMs` milliseconds.
|
||||
* Pass this key as a dependency to any useEffect to trigger re-fetch.
|
||||
*
|
||||
* @param intervalMs - Polling interval in ms. Default: 5 minutes.
|
||||
* @param immediate - If true, also trigger immediately on mount.
|
||||
*/
|
||||
export function usePollingKey(intervalMs: number = 5 * 60 * 1000): number {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalMs <= 0) return;
|
||||
const timer = setInterval(() => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
}, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [intervalMs]);
|
||||
|
||||
return refreshKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a manual refresh function + auto-polling refresh key.
|
||||
* Combine with hooks by passing refreshKey as a dependency.
|
||||
*
|
||||
* @param intervalMs - Auto-refresh interval in ms. Default: 5 minutes.
|
||||
*/
|
||||
export function useRefresh(intervalMs: number = 5 * 60 * 1000) {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [lastRefreshed, setLastRefreshed] = useState<Date>(new Date());
|
||||
|
||||
// Auto-refresh timer
|
||||
useEffect(() => {
|
||||
if (intervalMs <= 0) return;
|
||||
const timer = setInterval(() => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
setLastRefreshed(new Date());
|
||||
}, intervalMs);
|
||||
return () => clearInterval(timer);
|
||||
}, [intervalMs]);
|
||||
|
||||
// Manual refresh
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshKey(prev => prev + 1);
|
||||
setLastRefreshed(new Date());
|
||||
}, []);
|
||||
|
||||
return { refreshKey, lastRefreshed, refresh };
|
||||
}
|
||||
@@ -12,3 +12,119 @@ export function fmtBytes(bytes?: number | null) {
|
||||
if (i < 0) return '0 B';
|
||||
return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
|
||||
}
|
||||
|
||||
export function formatAppLabel(appLabel: string | null | undefined): string {
|
||||
if (!appLabel) return "-";
|
||||
const trimLabel = appLabel.trim();
|
||||
|
||||
// Match "Port XXX" or just "XXX"
|
||||
const portMatch = trimLabel.match(/^(?:Port\s+)?(\d+)$/i);
|
||||
if (portMatch) {
|
||||
const port = portMatch[1];
|
||||
const portNum = parseInt(port, 10);
|
||||
const PORT_DESCRIPTIONS: Record<string, string> = {
|
||||
"20": "FTP-Data",
|
||||
"21": "FTP Control (File Transfer)",
|
||||
"22": "SSH (Secure Shell / SFTP)",
|
||||
"23": "Telnet (Unencrypted Terminal)",
|
||||
"25": "SMTP (Mail Transfer)",
|
||||
"53": "DNS (Domain Name Lookup)",
|
||||
"67": "DHCP Server",
|
||||
"68": "DHCP Client",
|
||||
"69": "TFTP (Trivial FTP)",
|
||||
"80": "HTTP (Unencrypted Web Traffic)",
|
||||
"88": "Kerberos Authentication",
|
||||
"110": "POP3 (Email Retrieval)",
|
||||
"123": "NTP (Network Clock Sync)",
|
||||
"137": "NetBIOS Name Service",
|
||||
"138": "NetBIOS Datagram Service",
|
||||
"139": "NetBIOS Session Service",
|
||||
"143": "IMAP (Email Access)",
|
||||
"161": "SNMP (Network Management)",
|
||||
"162": "SNMP Trap",
|
||||
"389": "LDAP (Directory Access)",
|
||||
"443": "HTTPS (Encrypted Web Traffic)",
|
||||
"445": "SMB (Windows File Sharing)",
|
||||
"465": "SMTPS (Secure Mail)",
|
||||
"500": "ISAKMP/IPSec (VPN Exchange)",
|
||||
"514": "Syslog (System Logging)",
|
||||
"587": "Submission (Secure Mail)",
|
||||
"636": "LDAPS (Secure LDAP)",
|
||||
"993": "IMAPS (Secure IMAP)",
|
||||
"995": "POP3S (Secure POP3)",
|
||||
"1433": "MSSQL Database",
|
||||
"1434": "MSSQL Monitor",
|
||||
"1521": "Oracle DB",
|
||||
"1812": "RADIUS Auth",
|
||||
"1813": "RADIUS Acct",
|
||||
"1900": "SSDP (UPnP Device Discovery)",
|
||||
"3306": "MySQL/MariaDB DB",
|
||||
"3389": "RDP (Remote Desktop)",
|
||||
"5060": "SIP (VoIP Session)",
|
||||
"5061": "SIPS (Secure VoIP)",
|
||||
"5353": "mDNS (Multicast DNS)",
|
||||
"5432": "PostgreSQL DB",
|
||||
"5900": "VNC Remote Access",
|
||||
"6379": "Redis In-Memory DB",
|
||||
"8000": "Alt HTTP (Web Proxy/Dev)",
|
||||
"8080": "Alt HTTP (Web Proxy/Dev)",
|
||||
"8443": "Alt HTTPS (Web Interface)",
|
||||
"9200": "Elasticsearch",
|
||||
"27017": "MongoDB DB",
|
||||
};
|
||||
|
||||
if (PORT_DESCRIPTIONS[port]) {
|
||||
return `Port ${port} (${PORT_DESCRIPTIONS[port]})`;
|
||||
}
|
||||
|
||||
// Fallback classification for unknown ports
|
||||
return portNum >= 1024
|
||||
? `Port ${port} (Custom/Ephemeral)`
|
||||
: `Port ${port} (System Port)`;
|
||||
}
|
||||
|
||||
return trimLabel;
|
||||
}
|
||||
|
||||
export function getAppFavicon(appLabel: string): string | null {
|
||||
if (!appLabel) return null;
|
||||
const label = appLabel.toLowerCase();
|
||||
|
||||
// Mapping of common app labels to domains
|
||||
const domainMap: Record<string, string> = {
|
||||
facebook: 'facebook.com',
|
||||
youtube: 'youtube.com',
|
||||
google: 'google.com',
|
||||
zoom: 'zoom.us',
|
||||
cloudflare: 'cloudflare.com',
|
||||
ruijie: 'ruijie.com.cn',
|
||||
box: 'box.com',
|
||||
github: 'github.com',
|
||||
microsoft: 'microsoft.com',
|
||||
netflix: 'netflix.com',
|
||||
spotify: 'spotify.com',
|
||||
apple: 'apple.com',
|
||||
amazon: 'amazon.com',
|
||||
twitter: 'twitter.com',
|
||||
instagram: 'instagram.com',
|
||||
whatsapp: 'whatsapp.com',
|
||||
telegram: 'telegram.org',
|
||||
discord: 'discord.com',
|
||||
slack: 'slack.com',
|
||||
teams: 'teams.microsoft.com',
|
||||
};
|
||||
|
||||
for (const [key, domain] of Object.entries(domainMap)) {
|
||||
if (label.includes(key)) {
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain=${domain}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to a domain-like resolution if it contains dots, or return null
|
||||
if (appLabel.includes('.')) {
|
||||
return `https://www.google.com/s2/favicons?sz=64&domain=${appLabel}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user