feat: complete implementation of dynamic GlobeMap coordinates, TDD workflow, and UI/UX enhancements

This commit is contained in:
Rafif-Riqullah-Siregar committed 2026-07-07 23:25:59 +07:00
1 parent 8ba1d8f959
commit 764c87c3c8
75 files changed
+3666 -1153

No files matched your search

+8 -6
View File
@@ -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> &gt; <strong>API Keys</strong>.</li>
@@ -354,3 +355,4 @@ export default function AgentsPage() {
</div>
);
}
+10 -3
View File
@@ -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>
);
}
+123 -20
View File
@@ -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)}
/>
)}
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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>
+4 -2
View File
@@ -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>
);
}
+2 -1
View File
@@ -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' }}>
+2 -1
View File
@@ -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) => {
+6 -1
View File
@@ -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 */}
+54 -9
View File
@@ -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(() => {
+20 -18
View File
@@ -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>
);
}
+8 -3
View File
@@ -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>
);
}
+13 -37
View File
@@ -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);
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+8 -9
View File
@@ -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>
);
}
+3 -2
View File
@@ -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>
);
}
+34 -33
View File
@@ -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}
+38 -1
View File
@@ -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>
);
}
+74 -9
View File
@@ -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>
)}
+73 -70
View File
@@ -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) {
</>
);
}
+37 -98
View File
@@ -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>
);
}
+5 -5
View File
@@ -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
+98 -75
View File
@@ -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>
);
+30
View File
@@ -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;
}
+2 -1
View File
@@ -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
View File
@@ -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> {
+130
View File
@@ -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 -1
View File
@@ -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;