feat: complete implementation of dynamic GlobeMap coordinates, TDD workflow, and UI/UX enhancements
This commit is contained in:
1 parent
8ba1d8f959
commit
764c87c3c8
75 files changed
+3666
-1153
No files matched your search
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -66,7 +66,7 @@ export default function AgentsPage() {
|
||||
router.refresh();
|
||||
window.location.reload();
|
||||
} catch (err: any) {
|
||||
alert('Gagal masuk mode View As: ' + err.message);
|
||||
alert('Failed to enter View As mode: ' + err.message);
|
||||
} finally {
|
||||
setViewAsLoading(null);
|
||||
}
|
||||
@@ -210,7 +210,7 @@ export default function AgentsPage() {
|
||||
>
|
||||
{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="Hapus">
|
||||
<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>
|
||||
</>
|
||||
@@ -223,12 +223,13 @@ export default function AgentsPage() {
|
||||
|
||||
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">
|
||||
<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"
|
||||
@@ -253,7 +254,7 @@ export default function AgentsPage() {
|
||||
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>
|
||||
<p className="font-semibold text-white">💡 Cara Mengatasinya:</p>
|
||||
<ol className="list-decimal pl-4 space-y-1 text-slate-400">
|
||||
<li>Buka dan login ke portal <a href="https://portal.netify.ai" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline font-medium">BackOne Portal</a>.</li>
|
||||
<li>Di menu sebelah kiri, navigasikan ke <strong>Settings</strong> > <strong>API Keys</strong>.</li>
|
||||
@@ -354,3 +355,4 @@ export default function AgentsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTopApps, AppStat } from "@/lib/api";
|
||||
@@ -11,7 +11,9 @@ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip,
|
||||
|
||||
export default function AppsPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { data, isLoading } = useTopApps(50); // fetch 50 apps
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 20;
|
||||
const { data, isLoading } = useTopApps(pageSize, page * pageSize);
|
||||
const [selectedApp, setSelectedApp] = useState<AppStat | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,6 +25,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: "#", accessor: (row, i) => i + 1 },
|
||||
{
|
||||
header: "Application",
|
||||
@@ -57,7 +60,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
|
||||
@@ -68,6 +71,7 @@ export default function AppsPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
@@ -134,3 +138,6 @@ export default function AppsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,30 +3,108 @@
|
||||
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 } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useDevices, DeviceStat } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, ChevronRight } from "lucide-react";
|
||||
import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
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";
|
||||
const displayValue = placeholder;
|
||||
|
||||
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]">{displayValue}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-500 flex-shrink-0 ${isOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{/* Animated Dropdown Menu */}
|
||||
<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 whitespace-normal break-words transition-colors w-full ${value === "All" ? "bg-primary/20 text-white font-medium" : "text-slate-300 hover:bg-white/5 hover:text-white"}`}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => { onChange(opt); setIsOpen(false); }}
|
||||
className={`px-3 py-2 text-sm text-left whitespace-normal break-words transition-colors w-full ${value === opt ? "bg-primary/20 text-white font-medium" : "text-slate-300 hover:bg-white/5 hover:text-white"}`}
|
||||
title={opt}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const devices = useDevices(1000);
|
||||
const devices = useDevices(5000);
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
||||
|
||||
const [filterMan, setFilterMan] = useState<string>("All");
|
||||
const [filterType, setFilterType] = useState<string>("All");
|
||||
const [filterOS, setFilterOS] = useState<string>("All");
|
||||
|
||||
const [sortDownload, setSortDownload] = useState<string>("All");
|
||||
const [sortUpload, setSortUpload] = useState<string>("All");
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const manufacturers = Array.from(new Set(devices.data?.map(d => d.manufacturer).filter(Boolean) || [])).filter(v => v !== "-").sort() as string[];
|
||||
const deviceTypes = Array.from(new Set(devices.data?.map(d => d.device_type).filter(Boolean) || [])).filter(v => v !== "-").sort() as string[];
|
||||
const osLabels = Array.from(new Set(devices.data?.map(d => d.os_label).filter(Boolean) || [])).filter(v => v !== "-").sort() as string[];
|
||||
|
||||
const columns: Column<DeviceStat>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{
|
||||
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);
|
||||
if (!dateStr) return "-";
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<span className="text-sm font-medium">{dateStr.toLocaleDateString()}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{dateStr.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "IP Address",
|
||||
className: "w-[12%]",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setSelectedDevice(row)}
|
||||
className="flex items-center gap-1.5 group text-left"
|
||||
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">
|
||||
@@ -38,13 +116,14 @@ export default function DevicesPage() {
|
||||
},
|
||||
{
|
||||
header: "MAC Address",
|
||||
className: "w-[17%]",
|
||||
accessor: (row) => {
|
||||
const isRouted = row.is_gateway_routed === 1;
|
||||
const hasMan = row.manufacturer && row.manufacturer !== "-" && row.manufacturer !== "Unknown";
|
||||
const info = hasMan ? row.manufacturer : (row.device_type && row.device_type !== "-" && row.device_type !== "Generic Client" ? row.device_type : "");
|
||||
|
||||
return row.mac_address ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground w-fit font-mono">
|
||||
{row.mac_address}
|
||||
</code>
|
||||
@@ -60,21 +139,40 @@ export default function DevicesPage() {
|
||||
);
|
||||
},
|
||||
},
|
||||
{ header: "Type", accessor: (row) => row.device_type || "-" },
|
||||
{ header: "OS", accessor: (row) => row.os_label || "-" },
|
||||
{ header: "Manufacturer", accessor: (row) => row.manufacturer || "-" },
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>,
|
||||
className: "text-right",
|
||||
{
|
||||
header: <FilterSelect value={filterType} onChange={setFilterType} options={deviceTypes} placeholder="Type" />,
|
||||
accessor: (row) => row.device_type || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: <FilterSelect value={filterOS} onChange={setFilterOS} options={osLabels} placeholder="OS" />,
|
||||
accessor: (row) => row.os_label || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: <FilterSelect value={filterMan} onChange={setFilterMan} options={manufacturers} placeholder="Manufacturer" />,
|
||||
accessor: (row) => row.manufacturer || "-",
|
||||
className: "w-[14%]"
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
header: <FilterSelect value={sortDownload} onChange={(val) => { setSortDownload(val); setSortUpload("All"); }} options={["Descending", "Ascending"]} placeholder="Download" />,
|
||||
accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
{
|
||||
header: <FilterSelect value={sortUpload} onChange={(val) => { setSortUpload(val); setSortDownload("All"); }} options={["Descending", "Ascending"]} placeholder="Upload" />,
|
||||
accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>,
|
||||
className: "text-right",
|
||||
className: "w-[9%]",
|
||||
},
|
||||
];
|
||||
|
||||
let processedData = [...(devices.data || [])];
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -98,17 +196,23 @@ export default function DevicesPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={devices.data || []}
|
||||
data={processedData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search devices by IP, MAC, OS..."
|
||||
searchPlaceholder="Search devices by IP, MAC..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (
|
||||
const matchesSearch = (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.mac_address || "").toLowerCase().includes(q) ||
|
||||
(row.os_label || "").toLowerCase().includes(q) ||
|
||||
(row.device_type || "").toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
const matchesMan = filterMan === "All" || row.manufacturer === filterMan;
|
||||
const matchesType = filterType === "All" || row.device_type === filterType;
|
||||
const matchesOS = filterOS === "All" || row.os_label === filterOS;
|
||||
|
||||
return matchesSearch && matchesMan && matchesType && matchesOS;
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
@@ -118,8 +222,7 @@ export default function DevicesPage() {
|
||||
{selectedDevice && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip_address}
|
||||
deviceLabel={selectedDevice.device_label}
|
||||
macAddress={selectedDevice.mac_address}
|
||||
deviceData={selectedDevice}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -32,12 +32,13 @@ export default function DNSPage() {
|
||||
|
||||
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">
|
||||
DNS Intelligence
|
||||
{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>
|
||||
|
||||
@@ -105,12 +105,13 @@ function EventsContent() {
|
||||
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useFlows } from "@/lib/api";
|
||||
@@ -110,13 +110,14 @@ 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>
|
||||
@@ -142,3 +143,4 @@ export default function FlowsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function GeographyPage() {
|
||||
|
||||
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">
|
||||
Geographic Traffic
|
||||
@@ -74,6 +74,7 @@ export default function GeographyPage() {
|
||||
<p className="text-muted-foreground mt-1">Visualize data flow and activity across the globe.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{showMap && (
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
|
||||
@@ -228,12 +228,13 @@ export default function IntelligencePage() {
|
||||
|
||||
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">
|
||||
Deep Packet Inspection Intelligence
|
||||
{loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{intelItems.map((item, idx) => {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { DashboardLayout } from "@/components/layout/DashboardLayout";
|
||||
import { TimeFilterProvider } from "@/contexts/TimeFilterContext";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
return (
|
||||
<TimeFilterProvider>
|
||||
<DashboardLayout>{children}</DashboardLayout>
|
||||
</TimeFilterProvider>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ export default function NetworkIntelligencePage() {
|
||||
|
||||
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
|
||||
@@ -49,6 +49,7 @@ export default function NetworkIntelligencePage() {
|
||||
<span>DPI Engine Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Left Column: App Categories List */}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols, useCountries } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { GlobeMap } from "@/components/ui/GlobeMap";
|
||||
import { getOriginLocation, getDestinationLocation } from "@/lib/geoUtils";
|
||||
import Link from "next/link";
|
||||
|
||||
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
|
||||
@@ -21,19 +22,63 @@ export default function SummaryPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const summary = useDashboardSummary();
|
||||
const timeline = useTimeline(24);
|
||||
const topApps = useTopApps(7);
|
||||
const topProtocols = useTopProtocols();
|
||||
const rawTopApps = useTopApps(7);
|
||||
const rawTopProtocols = useTopProtocols();
|
||||
const countries = useCountries(20);
|
||||
|
||||
const { topProtocols, topApps } = useMemo(() => {
|
||||
const rawProtos = rawTopProtocols.data || [];
|
||||
let appsData = rawTopApps.data || [];
|
||||
|
||||
const standardProtocols = [
|
||||
'QUIC', 'HTTPS', 'HTTP', 'STUN', 'TCP', 'UDP', 'TLS', 'DNS', 'ICMP',
|
||||
'SSH', 'FTP', 'SMTP', 'POP3', 'IMAP', 'NTP', 'DHCP', 'BGP', 'OSPF', 'IPSEC', 'OPENVPN'
|
||||
];
|
||||
|
||||
const actualProtocols = rawProtos.filter(p => standardProtocols.includes(p.protocol_label?.toUpperCase()));
|
||||
const appProtocols = rawProtos.filter(p => !standardProtocols.includes(p.protocol_label?.toUpperCase()));
|
||||
|
||||
const convertedApps = appProtocols.map(p => ({
|
||||
app_label: p.protocol_label,
|
||||
download: p.download || 0,
|
||||
upload: p.upload || 0
|
||||
}));
|
||||
|
||||
let mergedApps = [...appsData.filter(a => a.app_label !== 'Unknown'), ...convertedApps];
|
||||
mergedApps.sort((a, b) => (b.download || 0) - (a.download || 0));
|
||||
mergedApps = mergedApps.slice(0, 7);
|
||||
|
||||
return {
|
||||
topProtocols: { ...rawTopProtocols, data: actualProtocols },
|
||||
topApps: { ...rawTopApps, data: mergedApps.length > 0 ? mergedApps : appsData }
|
||||
};
|
||||
}, [rawTopProtocols.data, rawTopApps.data, rawTopProtocols.isLoading, rawTopApps.isLoading]);
|
||||
|
||||
const mapData = useMemo(() => {
|
||||
if (!countries.data) return [];
|
||||
return countries.data.map((row) => ({
|
||||
countryCode: row.country_code,
|
||||
value: row.download + row.upload,
|
||||
download: row.download,
|
||||
upload: row.upload,
|
||||
label: row.country_name,
|
||||
}));
|
||||
|
||||
const origin = getOriginLocation(); // Falls back to default if no specific override
|
||||
|
||||
return countries.data.reduce((acc: any[], row) => {
|
||||
// Skip local-to-local if origin is Indonesia and destination is Indonesia
|
||||
if (row.country_code === "ID" && origin.label.includes("Indonesia")) return acc;
|
||||
|
||||
const dest = getDestinationLocation(row.country_code, row.country_name);
|
||||
if (dest) {
|
||||
acc.push({
|
||||
startLat: origin.lat,
|
||||
startLng: origin.lng,
|
||||
endLat: dest.lat,
|
||||
endLng: dest.lng,
|
||||
fromLabel: origin.label,
|
||||
toLabel: dest.label,
|
||||
value: row.download + row.upload,
|
||||
download: row.download,
|
||||
upload: row.upload,
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, [countries.data]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
@@ -26,9 +26,9 @@ const COLORS = {
|
||||
};
|
||||
|
||||
const RISK_CONFIG = {
|
||||
Rawan: { 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" },
|
||||
Sedang: { 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" },
|
||||
Aman: { 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" },
|
||||
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;
|
||||
|
||||
export default function SecurityAuditPage() {
|
||||
@@ -54,9 +54,9 @@ export default function SecurityAuditPage() {
|
||||
const isLoading = versions.isLoading || ciphers.isLoading || security.isLoading;
|
||||
|
||||
// Risk summary counts
|
||||
const rawan = secDevices.filter((d) => d.risk_level === "Rawan").length;
|
||||
const sedang = secDevices.filter((d) => d.risk_level === "Sedang").length;
|
||||
const aman = secDevices.filter((d) => d.risk_level === "Aman").length;
|
||||
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 },
|
||||
@@ -134,7 +134,7 @@ export default function SecurityAuditPage() {
|
||||
{
|
||||
header: "Risk Level",
|
||||
accessor: (row) => {
|
||||
const cfg = RISK_CONFIG[row.risk_level] ?? RISK_CONFIG.Aman;
|
||||
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}
|
||||
@@ -152,7 +152,7 @@ export default function SecurityAuditPage() {
|
||||
|
||||
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">
|
||||
Security Audit
|
||||
@@ -161,6 +161,7 @@ export default function SecurityAuditPage() {
|
||||
<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">
|
||||
@@ -186,32 +187,32 @@ export default function SecurityAuditPage() {
|
||||
<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">Rawan (Vulnerable)</CardTitle>
|
||||
<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">{rawan}</div>
|
||||
<p className="text-xs text-red-300/60 mt-1">device{rawan !== 1 ? "s" : ""} at risk</p>
|
||||
<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">Sedang (Moderate)</CardTitle>
|
||||
<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">{sedang}</div>
|
||||
<p className="text-xs text-orange-300/60 mt-1">device{sedang !== 1 ? "s" : ""} moderate risk</p>
|
||||
<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">Aman (Secure)</CardTitle>
|
||||
<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">{aman}</div>
|
||||
<p className="text-xs text-green-300/60 mt-1">device{aman !== 1 ? "s" : ""} secure</p>
|
||||
<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>
|
||||
@@ -377,3 +378,4 @@ export default function SecurityAuditPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
@@ -13,7 +13,9 @@ import { Suspense } from "react";
|
||||
|
||||
function ThreatsContent() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const threats = useThreats(100);
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 50;
|
||||
const threats = useThreats(pageSize, page * pageSize);
|
||||
const searchParams = useSearchParams();
|
||||
const highlightId = searchParams.get("highlight");
|
||||
|
||||
@@ -33,6 +35,7 @@ function ThreatsContent() {
|
||||
};
|
||||
|
||||
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) },
|
||||
@@ -54,7 +57,7 @@ function ThreatsContent() {
|
||||
|
||||
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
|
||||
@@ -67,6 +70,7 @@ 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' }}>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
@@ -103,3 +107,4 @@ export default function ThreatsPage() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,47 +25,28 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
const routeArray = (await params).route;
|
||||
const route = routeArray.join('/');
|
||||
|
||||
let destinationUrl = '';
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (routeArray[0] === 'auth' || routeArray[0] === 'dashboard') {
|
||||
const localBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001';
|
||||
destinationUrl = `${localBase}/api/${route}${url.search}`;
|
||||
} else {
|
||||
const baseUrl = process.env.BACKONE_BASE_URL || process.env.NETIFY_BASE_URL || 'https://manager.netify.ai/api/v1';
|
||||
destinationUrl = `${baseUrl}/${route}${url.search}`;
|
||||
}
|
||||
// Force ALL traffic to go through our own Centralized Proxy Server (Express Backend)
|
||||
const localBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001';
|
||||
const destinationUrl = `${localBase}/api/${route}${url.search}`;
|
||||
|
||||
// Prepare headers
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'application/json');
|
||||
|
||||
if (routeArray[0] !== 'auth' && routeArray[0] !== 'dashboard') {
|
||||
const apiKey = process.env.BACKONE_API_KEY || process.env.NETIFY_API_KEY;
|
||||
const jwtToken = process.env.BACKONE_JWT_TOKEN || process.env.NETIFY_JWT_TOKEN;
|
||||
const siteUuid = process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID;
|
||||
|
||||
const apiToken = apiKey || jwtToken;
|
||||
if (apiToken) {
|
||||
headers.set('x-api-key', apiToken);
|
||||
}
|
||||
if (siteUuid) {
|
||||
headers.set('x-net-site', siteUuid);
|
||||
}
|
||||
} else {
|
||||
// For local auth/dashboard routes, pass along the cookie for authentication
|
||||
const cookie = req.headers.get('cookie');
|
||||
if (cookie) {
|
||||
headers.set('cookie', cookie);
|
||||
}
|
||||
// Forward X-View-As-Agent header for view-as mode data filtering
|
||||
const viewAsHeader = req.headers.get('x-view-as-agent');
|
||||
if (viewAsHeader) {
|
||||
headers.set('x-view-as-agent', viewAsHeader);
|
||||
}
|
||||
// Forward cookie for authentication
|
||||
const cookie = req.headers.get('cookie');
|
||||
if (cookie) {
|
||||
headers.set('cookie', cookie);
|
||||
}
|
||||
|
||||
// Forward X-View-As-Agent header for view-as mode data filtering
|
||||
const viewAsHeader = req.headers.get('x-view-as-agent');
|
||||
if (viewAsHeader) {
|
||||
headers.set('x-view-as-agent', viewAsHeader);
|
||||
}
|
||||
|
||||
// Some requests like GET cannot have a body
|
||||
const hasBody = !['GET', 'HEAD'].includes(req.method);
|
||||
let body = null;
|
||||
|
||||
@@ -79,10 +60,8 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
body: body || undefined,
|
||||
});
|
||||
|
||||
// Return raw text so we don't try to parse it if it's not JSON
|
||||
const responseText = await response.text();
|
||||
|
||||
// Build the response
|
||||
const nextResponse = new NextResponse(responseText, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
@@ -90,15 +69,12 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
}
|
||||
});
|
||||
|
||||
// Forward ALL Set-Cookie headers individually so cookies reach the browser
|
||||
// This is critical for view_as_token and auth token cookies
|
||||
const rawCookies = response.headers.getSetCookie ? response.headers.getSetCookie() : [];
|
||||
if (rawCookies.length > 0) {
|
||||
rawCookies.forEach(cookie => {
|
||||
nextResponse.headers.append('Set-Cookie', cookie);
|
||||
});
|
||||
} else {
|
||||
// Fallback: single set-cookie header
|
||||
const setCookieHeader = response.headers.get('set-cookie');
|
||||
if (setCookieHeader) {
|
||||
nextResponse.headers.set('Set-Cookie', setCookieHeader);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import mongoose from 'mongoose';
|
||||
import { DeviceStat, Summary, AppStat, ProtocolStat, Flow, Threat } from '../../../../backend/models/Schemas';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check if mongoose is connected
|
||||
if (mongoose.connection.readyState !== 1) {
|
||||
return NextResponse.json({ error: 'MongoDB not connected on frontend route' }, { status: 500 });
|
||||
}
|
||||
|
||||
await DeviceStat.deleteMany({});
|
||||
await Summary.deleteMany({});
|
||||
await AppStat.deleteMany({});
|
||||
await ProtocolStat.deleteMany({});
|
||||
await Flow.deleteMany({});
|
||||
await Threat.deleteMany({});
|
||||
|
||||
return NextResponse.json({ message: 'All dummy data wiped from in-memory database successfully!' });
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -562,4 +562,29 @@ canvas {
|
||||
|
||||
.animate-overlay-out {
|
||||
animation: overlayFadeOut 500ms ease-in forwards;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide native Edge/IE password reveal icon (the second eye) */
|
||||
input::-ms-reveal,
|
||||
input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Accordion Animation Fix */
|
||||
.accordion-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 1s ease-in-out, margin-top 1s ease-in-out, opacity 1s ease-in-out;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.accordion-grid.expanded {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.accordion-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
+1
-1
@@ -32,7 +32,7 @@ export default function RootLayout({
|
||||
className={`${inter.variable} ${backoneFont.variable} h-full antialiased dark`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<body className="min-h-full flex flex-col" suppressHydrationWarning>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function AgentAccountModal({
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!existingUser) return;
|
||||
if (!confirm(`Hapus akun "${existingUser.username}"? Tindakan ini tidak dapat dibatalkan.`)) return;
|
||||
if (!confirm(`Delete account "${existingUser.username}"? This action cannot be undone.`)) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAdminAgentUser(existingUser.id);
|
||||
@@ -129,7 +129,7 @@ export default function AgentAccountModal({
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-blue-900/40 to-slate-900">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{isEdit ? "Edit Akun Agent" : "Buat Akun Agent Baru"}
|
||||
{isEdit ? "Edit Agent Account" : "Create New Agent Account"}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5 font-mono">{agentUuid}</p>
|
||||
</div>
|
||||
@@ -162,13 +162,13 @@ export default function AgentAccountModal({
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Nama Akun (Label Agent)
|
||||
Account Name (Label Agent)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={e => setAccountName(e.target.value)}
|
||||
placeholder="Contoh: Gateway 007"
|
||||
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>
|
||||
@@ -244,23 +244,21 @@ export default function AgentAccountModal({
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm hover:bg-red-900/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Hapus
|
||||
</button>
|
||||
Delete</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border border-slate-600 text-slate-300 text-sm hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{isEdit ? "Simpan Perubahan" : "Buat Akun"}
|
||||
{isEdit ? "Save Changes" : "Create Account"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -268,3 +266,4 @@ export default function AgentAccountModal({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Eye, ArrowLeft, Loader2 } from "lucide-react";
|
||||
@@ -50,8 +50,9 @@ export default function ViewAsAgentBanner() {
|
||||
) : (
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Kembali ke Admin
|
||||
Return to Admin
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Eye, EyeOff, Image as ImageIcon, Upload, Trash2 } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
@@ -72,12 +72,12 @@ export function AccountSettingsModal({
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Password berhasil diubah!");
|
||||
setSuccessMsg("Password changed successfully!");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
setErrorMsg(data.error || "Gagal mengubah password.");
|
||||
setErrorMsg(data.error || "Failed to change password.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
@@ -101,13 +101,13 @@ export function AccountSettingsModal({
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setUsernameSuccessMsg("Username berhasil diubah!");
|
||||
setUsernameSuccessMsg("Username changed successfully!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, username: data.newUsername });
|
||||
}
|
||||
} else {
|
||||
setUsernameErrorMsg(data.error || "Gagal mengubah username.");
|
||||
setUsernameErrorMsg(data.error || "Failed to change username.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setUsernameErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
@@ -131,13 +131,13 @@ export function AccountSettingsModal({
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setAccountNameSuccessMsg("Nama akun berhasil diubah!");
|
||||
setAccountNameSuccessMsg("Account name changed successfully!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, account_name: data.newAccountName });
|
||||
}
|
||||
} else {
|
||||
setAccountNameErrorMsg(data.error || "Gagal mengubah nama akun.");
|
||||
setAccountNameErrorMsg(data.error || "Failed to change account name.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAccountNameErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
@@ -165,13 +165,13 @@ export function AccountSettingsModal({
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProfilePicSuccessMsg("Foto profil berhasil diperbarui!");
|
||||
setProfilePicSuccessMsg("Profile picture updated successfully!");
|
||||
setProfilePicFile(null);
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: data.profile_picture });
|
||||
}
|
||||
} else {
|
||||
setProfilePicErrorMsg(data.error || "Gagal mengunggah foto profil.");
|
||||
setProfilePicErrorMsg(data.error || "Failed to upload profile picture.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProfilePicErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
@@ -192,12 +192,12 @@ export function AccountSettingsModal({
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setProfilePicSuccessMsg("Foto profil berhasil dihapus!");
|
||||
setProfilePicSuccessMsg("Profile picture deleted successfully!");
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: undefined });
|
||||
}
|
||||
} else {
|
||||
setProfilePicErrorMsg(data.error || "Gagal menghapus foto profil.");
|
||||
setProfilePicErrorMsg(data.error || "Failed to delete profile picture.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setProfilePicErrorMsg(err.message || "Terjadi kesalahan sistem.");
|
||||
@@ -235,7 +235,7 @@ export function AccountSettingsModal({
|
||||
activeTab === 'account_name' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Ubah Nama
|
||||
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)]" />
|
||||
)}
|
||||
@@ -259,7 +259,7 @@ export function AccountSettingsModal({
|
||||
activeTab === 'username' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Ubah Username
|
||||
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)]" />
|
||||
)}
|
||||
@@ -271,7 +271,7 @@ export function AccountSettingsModal({
|
||||
activeTab === 'password' ? "text-primary" : "text-slate-400 hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
Ubah Password
|
||||
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)]" />
|
||||
)}
|
||||
@@ -316,7 +316,7 @@ export function AccountSettingsModal({
|
||||
<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 : "Pilih File Gambar"}
|
||||
{profilePicFile ? profilePicFile.name : "Choose Image File"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,7 +342,7 @@ export function AccountSettingsModal({
|
||||
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">Hapus</span>
|
||||
<span className="text-[10px] font-medium">Delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -354,7 +354,7 @@ export function AccountSettingsModal({
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Tutup
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -379,7 +379,7 @@ export function AccountSettingsModal({
|
||||
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="Misal: Super Admin Budi"
|
||||
placeholder="e.g., Super Admin Budi"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -392,7 +392,7 @@ export function AccountSettingsModal({
|
||||
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="Ketik password untuk melanjutkan"
|
||||
placeholder="Enter password to continue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -410,7 +410,7 @@ export function AccountSettingsModal({
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Batal
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -420,7 +420,7 @@ export function AccountSettingsModal({
|
||||
{isSubmittingAccountName ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmittingAccountName ? "Menyimpan..." : "Simpan Nama Akun"}
|
||||
{isSubmittingAccountName ? "Saving..." : "Save Account Name"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -458,7 +458,7 @@ export function AccountSettingsModal({
|
||||
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="Ketik password untuk melanjutkan"
|
||||
placeholder="Enter password to continue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -476,7 +476,7 @@ export function AccountSettingsModal({
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Batal
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -486,7 +486,7 @@ export function AccountSettingsModal({
|
||||
{isSubmittingUsername ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmittingUsername ? "Menyimpan..." : "Simpan Username"}
|
||||
{isSubmittingUsername ? "Saving..." : "Save Username"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -533,7 +533,7 @@ export function AccountSettingsModal({
|
||||
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="Ketik password baru"
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -554,7 +554,7 @@ export function AccountSettingsModal({
|
||||
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="Ketik ulang password baru"
|
||||
placeholder="Re-enter new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -571,19 +571,19 @@ export function AccountSettingsModal({
|
||||
<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
|
||||
<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)
|
||||
<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)
|
||||
<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)
|
||||
<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
|
||||
<span className="mr-2.5 text-sm">{passwordsMatch ? "✓" : "○"}</span> Password cocok
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -594,7 +594,7 @@ export function AccountSettingsModal({
|
||||
onClick={() => onClose()}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Batal
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -604,7 +604,7 @@ export function AccountSettingsModal({
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmitting ? "Menyimpan..." : "Simpan Password"}
|
||||
{isSubmitting ? "Saving..." : "Save Password"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -613,3 +613,4 @@ export function AccountSettingsModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Header } from "./Header";
|
||||
|
||||
export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@@ -10,7 +9,6 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
<Sidebar />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden w-full min-w-0 relative transition-all duration-500 ease-in-out">
|
||||
<Header />
|
||||
<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}
|
||||
|
||||
@@ -1,7 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import ViewAsAgentBanner from "@/components/admin/ViewAsAgentBanner";
|
||||
|
||||
export function Header() {
|
||||
return <ViewAsAgentBanner />;
|
||||
const pathname = usePathname();
|
||||
|
||||
const getPageTitle = () => {
|
||||
if (pathname === "/apps") return "Applications";
|
||||
if (pathname === "/devices") return "Devices Inventory";
|
||||
if (pathname === "/threats") return "Cyber Threats";
|
||||
if (pathname === "/flows") return "Network Flows";
|
||||
if (pathname === "/dns") return "DNS Queries";
|
||||
if (pathname === "/events") return "Security Events";
|
||||
return "Network Overview";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full z-50 sticky top-0 bg-background/70 backdrop-blur-xl border-b border-border/40">
|
||||
<ViewAsAgentBanner />
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 h-16">
|
||||
{/* Left Side: Page Title Only (Minimalist) */}
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<h1 className="text-xl font-bold text-foreground flex items-center tracking-tight">
|
||||
{getPageTitle()}
|
||||
{pathname === '/threats' && (
|
||||
<span className="ml-3 flex h-2 w-2 relative">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Empty for extreme minimalist look */}
|
||||
<div className="flex items-center">
|
||||
{/* Ornamen lain bisa ditambahkan di masa depan jika diperlukan */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,13 +5,15 @@ import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard, Server, Activity, Globe, Radio, Zap,
|
||||
ShieldAlert, Shield, Lock, AppWindow, Search,
|
||||
Bell, Check, Clock, User, LogOut, Sun, Moon, AlertTriangle, Info, Calendar, Router
|
||||
Bell, Check, Clock, ChevronDown, User, LogOut, Sun, Moon, AlertTriangle, Info, Calendar, Router
|
||||
} 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 { AccountSettingsModal } from "./AccountSettingsModal";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
|
||||
interface AppNotification {
|
||||
id: string;
|
||||
@@ -66,21 +68,36 @@ const navigationGroups = [
|
||||
}
|
||||
];
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
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 [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);
|
||||
@@ -123,6 +140,7 @@ export function Sidebar() {
|
||||
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);
|
||||
@@ -202,14 +220,14 @@ export function Sidebar() {
|
||||
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 */}
|
||||
<div className="flex flex-col p-4 shrink-0 gap-4 border-b border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/backone-logo.png" alt="BackOne Logo" className="w-8 h-8 rounded-full drop-shadow-[0_0_10px_rgba(255,0,0,0.3)]" />
|
||||
<div className="flex flex-col min-w-0 justify-center">
|
||||
<span className="text-lg font-bold font-backone text-foreground tracking-tight leading-none">
|
||||
<div className="flex flex-col p-2 shrink-0 gap-4 border-b border-white/5">
|
||||
<div className="flex items-center justify-center gap-3 w-full pt-4 pb-2 px-1">
|
||||
<img src="/backone-logo.png" alt="BackOne Logo" className="w-9 h-9 rounded-full drop-shadow-[0_0_12px_rgba(255,0,0,0.35)] shrink-0" />
|
||||
<div className="flex flex-col min-w-0 justify-center text-left">
|
||||
<span className="text-xl font-bold font-backone text-foreground tracking-tight leading-none">
|
||||
BackOne
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5 leading-tight tracking-wide whitespace-nowrap">
|
||||
<span className="text-[11px] text-muted-foreground mt-1 leading-tight tracking-wider whitespace-nowrap">
|
||||
Deep Package Inspection
|
||||
</span>
|
||||
</div>
|
||||
@@ -229,6 +247,53 @@ export function Sidebar() {
|
||||
<span className="truncate">{intervalText}</span>
|
||||
</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>
|
||||
|
||||
@@ -322,7 +387,7 @@ export function Sidebar() {
|
||||
}}
|
||||
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" /> Pengaturan Akun
|
||||
<User className="h-4 w-4" /> Account Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -343,7 +408,7 @@ export function Sidebar() {
|
||||
}}
|
||||
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" /> Keluar
|
||||
<LogOut className="h-4 w-4" /> Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchAgentDetails, AgentDetails } from "@/lib/api";
|
||||
@@ -19,7 +19,7 @@ interface Props {
|
||||
|
||||
type Tab = "devices" | "flows" | "apps" | "security" | "events" | "mac";
|
||||
|
||||
// ── Severity colour helper ─────────────────────────────────────────────────
|
||||
// ── 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" };
|
||||
@@ -32,17 +32,17 @@ function severityStyle(sev: string | null) {
|
||||
|
||||
function riskStyle(level: string | null) {
|
||||
switch ((level || "").toLowerCase()) {
|
||||
case "rawan":
|
||||
case "vulnerable":
|
||||
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
|
||||
case "sedang":
|
||||
case "moderate":
|
||||
case "medium": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
|
||||
case "aman":
|
||||
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 ────────────────────────────────────────────────────
|
||||
// ── Shared table wrapper ────────────────────────────────────────────────────
|
||||
function TableWrap({ children, head }: { children: React.ReactNode; head: string[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-border/50">
|
||||
@@ -64,7 +64,7 @@ function EmptyState({ msg }: { msg: string }) {
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">{msg}</p>;
|
||||
}
|
||||
|
||||
// ── Main component ──────────────────────────────────────────────────────────
|
||||
// ── Main component ──────────────────────────────────────────────────────────
|
||||
export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
const [data, setData] = useState<AgentDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -114,7 +114,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,24 +130,24 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
{/* ── 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.devices.length, color: "text-card-foreground" },
|
||||
{ label: "Flows", value: data.summary?.active_flows ?? data.flows.length, color: "text-card-foreground" },
|
||||
{ label: "Download", value: fmtBytes(data.summary?.bandwidth_down ?? 0), color: "text-cyan-400" },
|
||||
{ label: "Upload", value: fmtBytes(data.summary?.bandwidth_up ?? 0), color: "text-green-400" },
|
||||
{ label: "Devices", value: data.summary?.total_devices || data.devices.length, color: "text-card-foreground" },
|
||||
{ label: "Flows", value: data.summary?.active_flows || data.flows.length, color: "text-card-foreground" },
|
||||
{ label: "Download", value: fmtBytes(data.summary?.bandwidth_down || data.devices.reduce((acc, d) => acc + (d.download ?? 0), 0)), color: "text-cyan-400" },
|
||||
{ label: "Upload", value: fmtBytes(data.summary?.bandwidth_up || data.devices.reduce((acc, d) => acc + (d.upload ?? 0), 0)), color: "text-green-400" },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.label} className="bg-secondary rounded-xl p-3 border border-border/50">
|
||||
<p className="text-xs text-muted-foreground mb-1">{kpi.label}</p>
|
||||
@@ -156,15 +156,15 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Security alert strip ── */}
|
||||
{/* ── 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>
|
||||
<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,7 +187,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Tab content ── */}
|
||||
{/* ── Tab content ── */}
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
|
||||
{/* DEVICES */}
|
||||
@@ -217,7 +217,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
)}
|
||||
{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
|
||||
âš Insecure
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -229,10 +229,10 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -247,22 +247,22 @@ 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"]}>
|
||||
<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 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> : ""}
|
||||
{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 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}>
|
||||
@@ -271,10 +271,10 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">{f.protocol || "—"}</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>
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
@@ -301,8 +301,8 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<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>
|
||||
<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}%` }} />
|
||||
@@ -329,8 +329,8 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
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 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">
|
||||
@@ -342,7 +342,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<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 || "—"}
|
||||
{r.risk_level || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-cyan-400">{fmtBytes(r.encrypted ?? 0)}</td>
|
||||
@@ -361,24 +361,24 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<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", "↓", "↑"]}>
|
||||
<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 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 || "—"}
|
||||
{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 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 ?? "?"}` : "—"}
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</span>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
</div>
|
||||
@@ -404,21 +404,21 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
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 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 ?? "?"}` : "—"}
|
||||
{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 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 || "—"}
|
||||
{r.severity || "—"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -438,15 +438,15 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<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 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>
|
||||
? <span className="text-red-400 font-semibold">â›” Yes</span>
|
||||
: <span className="text-green-400">✓ No</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -468,10 +468,10 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
: <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>
|
||||
<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 className="text-muted-foreground mt-0.5">Exit: {r.exit_node || "—"}</div>
|
||||
</div>
|
||||
))}</div>
|
||||
}
|
||||
@@ -488,11 +488,11 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
: <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>
|
||||
<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>
|
||||
<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>}
|
||||
@@ -514,15 +514,15 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -546,17 +546,17 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<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 || "—"}
|
||||
{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>
|
||||
<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>
|
||||
<span className="text-slate-600 flex-shrink-0 whitespace-nowrap">{ev.event_at?.slice(0, 16) || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -586,8 +586,8 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
</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>
|
||||
<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}%` }} />
|
||||
@@ -616,3 +616,6 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"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";
|
||||
|
||||
@@ -17,6 +18,7 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const { timeRange } = useTimeFilter();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
@@ -28,11 +30,11 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
fetchAppDetails(appLabel)
|
||||
fetchAppDetails(appLabel, timeRange)
|
||||
.then(setData)
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [appLabel]);
|
||||
}, [appLabel, timeRange]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
@@ -88,112 +90,47 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-Agent Breakdown */}
|
||||
{/* Devices accessing this app */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-400" />
|
||||
Usage per Agent
|
||||
<Monitor className="w-4 h-4 text-blue-400" />
|
||||
Devices Accessing {appLabel}
|
||||
</h4>
|
||||
{data.agent_scorecard.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">No traffic from known agents for this app yet.</p>
|
||||
{(!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="space-y-2">
|
||||
{data.agent_scorecard.map((a, i) => {
|
||||
const maxDl = Math.max(...data.agent_scorecard.map(x => x.download), 1);
|
||||
const pct = (a.download / maxDl) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] border border-border/50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400" />
|
||||
<span className="text-sm font-medium text-card-foreground">{a.agent_label}</span>
|
||||
{a.agent_uuid && (
|
||||
<span className="text-xs font-mono text-muted-foreground">{a.agent_uuid}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs font-mono">
|
||||
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
|
||||
<span className="text-green-400">↑ {fmtBytes(a.upload ?? 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top IPs */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4 text-cyan-400" />
|
||||
Top 5 Flow Records (Realtime)
|
||||
{data.top_ips.filter(ip => ip.blacklisted).length > 0 && (
|
||||
<span className="ml-auto text-xs text-red-400 bg-red-500/10 px-2 py-0.5 rounded-full ring-1 ring-red-500/30">
|
||||
🚫 {data.top_ips.filter(ip => ip.blacklisted).length} Blacklisted
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
{data.top_ips.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-4">No device data available.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left border-b border-white/10">
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground w-8">#</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Source IP</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Dest IP</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Label / Domain</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Time</th>
|
||||
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground text-right">Download</th>
|
||||
<th className="pb-3 text-xs font-medium text-muted-foreground text-right">Upload</th>
|
||||
<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-white/5">
|
||||
{data.top_ips.map((ip, i) => {
|
||||
const maxDl = Math.max(...data.top_ips.map(x => x.download), 1);
|
||||
const pct = (ip.download / maxDl) * 100;
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors group">
|
||||
<td className="py-3 pr-4 text-xs text-muted-foreground group-hover:text-white transition-colors">{i + 1}</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className={ip.blacklisted ? "text-red-400" : "text-blue-400"}>{ip.ip_address || "-"}</span>
|
||||
{ip.blacklisted && (
|
||||
<span className="text-xs text-red-300 bg-red-500/10 px-1.5 py-0.5 rounded-full ring-1 ring-red-500/30">🚫</span>
|
||||
)}
|
||||
{!ip.blacklisted && ip.reputation && ip.reputation !== "Unknown" && (
|
||||
<span className="text-xs text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">{ip.reputation}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4 font-mono text-xs text-muted-foreground truncate max-w-[120px]">
|
||||
{ip.dst_ip || "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-xs text-slate-300 truncate max-w-[150px]">
|
||||
{ip.domain || data.app_label || "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-xs text-muted-foreground whitespace-nowrap">
|
||||
{ip.last_seen ? new Date(ip.last_seen).toLocaleString('id-ID', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right text-xs font-medium text-cyan-400">{fmtBytes(ip.download)}</td>
|
||||
<td className="py-3 text-right text-xs font-medium text-green-400">{fmtBytes(ip.upload ?? 0)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -201,3 +138,5 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
export interface Column<T> {
|
||||
header: string;
|
||||
header: React.ReactNode;
|
||||
accessor: (row: T, index: number) => React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -58,11 +58,11 @@ export function DataTable<T>({
|
||||
)}
|
||||
<div className="rounded-xl border border-border overflow-hidden bg-card/50 backdrop-blur-xl shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<table className="w-full text-sm text-left table-fixed">
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50">
|
||||
<tr>
|
||||
{columns.map((col, i) => (
|
||||
<th key={i} className={`px-4 py-3.5 font-medium tracking-wide ${col.className || ""}`}>
|
||||
<th key={i} className={`px-2 py-3.5 font-medium tracking-wide text-center ${col.className || ""}`}>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
@@ -98,10 +98,10 @@ export function DataTable<T>({
|
||||
paginatedData.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className={`hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
className={`h-[64px] hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} className={`px-4 py-3 text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
<td key={j} className={`px-2 py-3 text-center text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
</td>
|
||||
))}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -48,11 +48,15 @@ const ORIGIN_LNG = 106.8456;
|
||||
|
||||
interface GlobeMapProps {
|
||||
data: {
|
||||
countryCode: string;
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
endLat: number;
|
||||
endLng: number;
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
value: number;
|
||||
upload?: number;
|
||||
download?: number;
|
||||
label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -109,12 +113,11 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
controls.minDistance = 140; // Zoom in limit
|
||||
controls.maxDistance = 350; // Zoom out limit
|
||||
|
||||
// Listen for interactions to pause auto-rotate smoothly
|
||||
controls.addEventListener('start', () => setUserInteracting(true));
|
||||
controls.addEventListener('end', () => setUserInteracting(false));
|
||||
|
||||
// Focus on Southeast Asia initially
|
||||
globeRef.current.pointOfView({ lat: ORIGIN_LAT, lng: ORIGIN_LNG, altitude: 1.8 }, 2000);
|
||||
// Focus on Southeast Asia initially (or dynamically from first data point if available)
|
||||
globeRef.current.pointOfView({ lat: -6.2, lng: 106.8, altitude: 1.8 }, 2000);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
@@ -139,12 +142,11 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
|
||||
// Smooth Material Color Interpolation for Theme Switching
|
||||
useEffect(() => {
|
||||
// Target ocean colors
|
||||
const targetColor = new THREE.Color(isLight ? '#e0f2fe' : '#020617');
|
||||
|
||||
let frameId: number;
|
||||
const lerpColor = () => {
|
||||
customGlobeMaterial.color.lerp(targetColor, 0.08); // Smooth interpolation step
|
||||
customGlobeMaterial.color.lerp(targetColor, 0.08);
|
||||
|
||||
const rDiff = Math.abs(customGlobeMaterial.color.r - targetColor.r);
|
||||
const gDiff = Math.abs(customGlobeMaterial.color.g - targetColor.g);
|
||||
@@ -163,47 +165,50 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
|
||||
// Prepare Data Memos
|
||||
const arcsData = useMemo(() => {
|
||||
return data
|
||||
.filter(d => d.countryCode !== "ID" && COUNTRY_COORDS[d.countryCode])
|
||||
.map(d => {
|
||||
const [lng, lat] = COUNTRY_COORDS[d.countryCode];
|
||||
return {
|
||||
startLat: ORIGIN_LAT,
|
||||
startLng: ORIGIN_LNG,
|
||||
endLat: lat,
|
||||
endLng: lng,
|
||||
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
|
||||
name: d.label,
|
||||
value: d.value,
|
||||
upload: d.upload || 0,
|
||||
download: d.download || 0
|
||||
};
|
||||
});
|
||||
return data.map(d => ({
|
||||
...d,
|
||||
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
|
||||
}));
|
||||
}, [data, isLight]);
|
||||
|
||||
const pointsData = useMemo(() => {
|
||||
const points = arcsData.map(arc => ({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
size: 0.1,
|
||||
color: isLight ? '#0284c7' : '#38bdf8',
|
||||
name: arc.name,
|
||||
value: arc.value,
|
||||
upload: arc.upload,
|
||||
download: arc.download,
|
||||
isOrigin: false
|
||||
}));
|
||||
|
||||
points.push({
|
||||
lat: ORIGIN_LAT,
|
||||
lng: ORIGIN_LNG,
|
||||
size: 0.2,
|
||||
color: '#f43f5e',
|
||||
name: 'Indonesia (Origin)',
|
||||
value: 0,
|
||||
upload: 0,
|
||||
download: 0,
|
||||
isOrigin: true
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
return points;
|
||||
@@ -211,22 +216,36 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
|
||||
// Extract All Labels + Origin to display statically
|
||||
const labelsData = useMemo(() => {
|
||||
const staticLabels = arcsData.map(arc => ({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
name: arc.name,
|
||||
color: isLight ? '#0f172a' : '#f8fafc'
|
||||
}));
|
||||
|
||||
// Add origin
|
||||
staticLabels.push({
|
||||
lat: ORIGIN_LAT,
|
||||
lng: ORIGIN_LNG,
|
||||
name: 'Indonesia',
|
||||
color: isLight ? '#e11d48' : '#fda4af'
|
||||
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 staticLabels;
|
||||
return labels;
|
||||
}, [arcsData, isLight]);
|
||||
|
||||
return (
|
||||
@@ -247,14 +266,12 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
atmosphereColor={isLight ? "#38bdf8" : "#1e40af"}
|
||||
atmosphereAltitude={isLight ? 0.15 : 0.25}
|
||||
|
||||
// Polygons Configuration
|
||||
polygonsData={countries ? countries.features : []}
|
||||
polygonAltitude={0.015}
|
||||
polygonCapColor={() => isLight ? '#4ade80' : '#0f172a'}
|
||||
polygonSideColor={() => isLight ? '#22c55e' : '#020617'}
|
||||
polygonStrokeColor={() => isLight ? '#ffffff' : '#0ea5e9'}
|
||||
|
||||
// Arcs configuration
|
||||
arcsData={arcsData}
|
||||
arcStartLat={(d: any) => d.startLat}
|
||||
arcStartLng={(d: any) => d.startLng}
|
||||
@@ -264,11 +281,21 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
arcDashLength={0.4}
|
||||
arcDashGap={0.2}
|
||||
arcDashAnimateTime={2000}
|
||||
arcAltitude={0.3}
|
||||
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);
|
||||
}}
|
||||
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;">Flow to ${d.name}</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: #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>
|
||||
@@ -281,7 +308,6 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
</div>
|
||||
`}
|
||||
|
||||
// Points configuration
|
||||
pointsData={pointsData}
|
||||
pointLat="lat"
|
||||
pointLng="lng"
|
||||
@@ -306,7 +332,6 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
</div>
|
||||
`}
|
||||
|
||||
// Rings configuration (Ping effect for points)
|
||||
ringsData={pointsData}
|
||||
ringLat="lat"
|
||||
ringLng="lng"
|
||||
@@ -315,16 +340,14 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
ringPropagationSpeed={3}
|
||||
ringRepeatPeriod={1000}
|
||||
|
||||
// Labels Configuration (All Destinations + Origin)
|
||||
labelsData={labelsData}
|
||||
labelLat="lat"
|
||||
labelLng="lng"
|
||||
labelLat={(d: any) => d.lat}
|
||||
labelLng={(d: any) => d.lng}
|
||||
labelDotRadius={0}
|
||||
labelSize={1.5}
|
||||
labelText="name"
|
||||
labelSize={1.4}
|
||||
labelDotRadius={0.2}
|
||||
labelColor="color"
|
||||
labelResolution={5}
|
||||
labelAltitude={0.03}
|
||||
labelColor={(d: any) => d.color}
|
||||
labelResolution={2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
type TimeRange = '5m' | '10m' | '30m' | '1h' | '1d' | '7d' | '30d';
|
||||
|
||||
interface TimeFilterContextType {
|
||||
timeRange: TimeRange;
|
||||
setTimeRange: (range: TimeRange) => void;
|
||||
}
|
||||
|
||||
const TimeFilterContext = createContext<TimeFilterContextType | undefined>(undefined);
|
||||
|
||||
export function TimeFilterProvider({ children }: { children: ReactNode }) {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('1h');
|
||||
|
||||
return (
|
||||
<TimeFilterContext.Provider value={{ timeRange, setTimeRange }}>
|
||||
{children}
|
||||
</TimeFilterContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTimeFilter() {
|
||||
const context = useContext(TimeFilterContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTimeFilter must be used within a TimeFilterProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export async function startViewAs(agent_uuid: string, agent_label: string): Prom
|
||||
throw new Error(`Server error (${res.status}): Backend mungkin perlu di-restart`);
|
||||
}
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Gagal masuk mode view-as');
|
||||
if (!json.ok) throw new Error(json.error || 'Failed to enter view-as mode');
|
||||
|
||||
// Simpan token di localStorage — akan dikirim sebagai X-View-As-Agent header
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -140,3 +140,4 @@ export function resolveAgentLabel(agentUuid: string, users: ManagedUser[]): stri
|
||||
const user = users.find(u => u.agent_uuid === agentUuid && u.role === 'AGENT_VIEWER');
|
||||
return user?.account_name || agentUuid;
|
||||
}
|
||||
|
||||
+16
-15
@@ -115,6 +115,8 @@ export interface AppStat {
|
||||
upload: number;
|
||||
favicon?: string;
|
||||
category?: string;
|
||||
first_seen?: string;
|
||||
last_seen?: string;
|
||||
}
|
||||
|
||||
export interface ProtocolStat {
|
||||
@@ -764,22 +766,21 @@ export interface DeviceMacBandwidth {
|
||||
}
|
||||
|
||||
export interface DeviceDetails {
|
||||
ip: string;
|
||||
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;
|
||||
flow_count: number;
|
||||
device_info: DeviceInfo;
|
||||
top_apps: DeviceAppItem[];
|
||||
top_domains: DeviceDomainItem[];
|
||||
flows: DeviceFlowItem[];
|
||||
encryption: DeviceEncryption | null;
|
||||
server_discovery: DeviceServerItem[];
|
||||
unencrypted_passwords: DevicePwdItem[];
|
||||
ip_reputation: DeviceReputationItem[];
|
||||
vpn_detections: DeviceVpnItem[];
|
||||
events: DeviceEventItem[];
|
||||
mac_bandwidth: DeviceMacBandwidth | null;
|
||||
flows: FlowStat[];
|
||||
apps: AppStat[];
|
||||
protocols: AppStat[];
|
||||
domains: AppStat[];
|
||||
destinations: AppStat[];
|
||||
threats: ThreatStat[];
|
||||
}
|
||||
|
||||
|
||||
@@ -834,8 +835,8 @@ export async function fetchAgentDetails(uuid: string): Promise<AgentDetails> {
|
||||
return fetcher<AgentDetails>(`/agent-details?uuid=${encodeURIComponent(uuid)}`);
|
||||
}
|
||||
|
||||
export async function fetchDeviceDetails(ip: string): Promise<DeviceDetails> {
|
||||
return fetcher<DeviceDetails>(`/device-details?ip=${encodeURIComponent(ip)}`);
|
||||
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> {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
export interface GeoLocation {
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// ─── 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],
|
||||
};
|
||||
|
||||
// ─── 2. MANUAL CONFIGURATION (For Local IPs, Agent UUIDs, or City Names) ───
|
||||
// Users can edit this section to map their internal locations accurately.
|
||||
export const MANUAL_OVERRIDES: Record<string, GeoLocation> = {
|
||||
// Map by IP
|
||||
"192.168.201.30": { lat: -7.2504, lng: 112.7688, label: "Surabaya, Indonesia" },
|
||||
|
||||
// Map by Network Agent UUID (Example UUIDs)
|
||||
"Balaraja-Agent": { lat: -6.1915, lng: 106.4526, label: "Balaraja, Indonesia" },
|
||||
"F6-2V-DT-8A": { lat: -6.1915, lng: 106.4526, label: "Balaraja, Indonesia" },
|
||||
"2F-TF-1D-GK": { lat: -6.9147, lng: 107.6098, label: "Bandung, Indonesia" },
|
||||
|
||||
// Map by City Names
|
||||
"Jakarta": { lat: -6.2088, lng: 106.8456, label: "Jakarta, Indonesia" },
|
||||
"Surabaya": { lat: -7.2504, lng: 112.7688, label: "Surabaya, Indonesia" },
|
||||
"Bandung": { lat: -6.9147, lng: 107.6098, label: "Bandung, Indonesia" },
|
||||
"Balaraja": { lat: -6.1915, lng: 106.4526, label: "Balaraja, Indonesia" },
|
||||
};
|
||||
|
||||
// Default fallback origin if nothing matches
|
||||
export const DEFAULT_ORIGIN: GeoLocation = {
|
||||
lat: -6.2088,
|
||||
lng: 106.8456,
|
||||
label: "Jakarta, Indonesia"
|
||||
};
|
||||
|
||||
export const COUNTRY_CAPITALS: Record<string, string> = {
|
||||
"ID": "Jakarta",
|
||||
"US": "Washington D.C.",
|
||||
"HK": "Hong Kong",
|
||||
"SG": "Singapore",
|
||||
"JP": "Tokyo",
|
||||
"SC": "Victoria",
|
||||
"GB": "London",
|
||||
"IT": "Rome",
|
||||
"CN": "Beijing",
|
||||
"KR": "Seoul",
|
||||
"CA": "Ottawa",
|
||||
"LU": "Luxembourg",
|
||||
"NL": "Amsterdam",
|
||||
"TR": "Ankara",
|
||||
"UA": "Kyiv",
|
||||
"RU": "Moscow",
|
||||
"HU": "Budapest",
|
||||
"BD": "Dhaka",
|
||||
"AU": "Canberra",
|
||||
"IN": "New Delhi",
|
||||
"DE": "Berlin",
|
||||
"FR": "Paris",
|
||||
"ES": "Madrid",
|
||||
"BR": "Brasília",
|
||||
"MY": "Kuala Lumpur",
|
||||
"TH": "Bangkok",
|
||||
"VN": "Hanoi",
|
||||
"PH": "Manila",
|
||||
"TW": "Taipei",
|
||||
"GR": "Athens",
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the exact origin coordinates based on IP, Agent, or City overrides.
|
||||
*/
|
||||
export function getOriginLocation(identifier?: string): GeoLocation {
|
||||
if (identifier && MANUAL_OVERRIDES[identifier]) {
|
||||
const override = MANUAL_OVERRIDES[identifier];
|
||||
// Ensure format is "City, Country"
|
||||
if (!override.label.includes(",")) {
|
||||
return { ...override, label: `${override.label}, Indonesia` };
|
||||
}
|
||||
return override;
|
||||
}
|
||||
return DEFAULT_ORIGIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the exact destination coordinates based on Country Code.
|
||||
*/
|
||||
export function getDestinationLocation(countryCode: string, countryName?: string): GeoLocation | null {
|
||||
const coords = COUNTRY_COORDS[countryCode];
|
||||
if (!coords) return null;
|
||||
|
||||
const cityName = COUNTRY_CAPITALS[countryCode] || "Unknown City";
|
||||
const cName = countryName || countryCode;
|
||||
|
||||
// Note: COUNTRY_COORDS uses [lng, lat] format
|
||||
return {
|
||||
lat: coords[1],
|
||||
lng: coords[0],
|
||||
label: `${cityName}, ${cName}`
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
export function proxy(request: NextRequest) {
|
||||
const token = request.cookies.get('token')?.value;
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
Reference in new issue
Block a user