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

This commit is contained in:
Rafif-Riqullah-Siregar committed 2026-07-10 11:52:58 +07:00
1 parent 6cf3c6c7e5
commit c316f3171b
226 files changed
+21152 -8397

No files matched your search

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