This commit is contained in:
vanne committed 2026-06-30 17:06:08 +07:00
1 parent da8fb57bfb
commit bba75c7dc6
35 files changed
+92592 -500

No files matched your search

+72 -7
View File
@@ -6,7 +6,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable, Column } from "@/components/ui/DataTable";
import { Modal } from "@/components/ui/Modal";
import { Badge } from "@/components/ui/Badge";
import { Loader2, Plus, Edit, Trash2, Server, CheckCircle2, XCircle } from "lucide-react";
import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
import { Loader2, Plus, Edit, Trash2, Server, CheckCircle2, XCircle, ChevronRight } from "lucide-react";
export default function AgentsPage() {
const [agents, setAgents] = useState<Agent[]>([]);
@@ -18,6 +19,16 @@ export default function AgentsPage() {
const [isEditOpen, setIsEditOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
// Detail modal
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
// Known agent UUIDs with labels
const AGENT_LABELS: Record<string, string> = {
'2F-TF-1D-GK': 'JRP Cibubur',
'8A-V3-PB-85': 'IFG LT.18',
'F6-2V-DT-8A': 'CPI Balaraja',
};
// Form state
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [formData, setFormData] = useState({ agentId: "", label: "" });
@@ -100,8 +111,30 @@ export default function AgentsPage() {
};
const columns: Column<Agent>[] = [
{ header: "UUID / Serial", accessor: (row) => <span className="font-mono text-xs text-blue-400">{row.uuid || row.serial}</span> },
{ header: "Label", accessor: (row) => row.label || <span className="text-muted-foreground italic">No label</span> },
{
header: "UUID / Serial",
accessor: (row) => (
<button
onClick={() => setDetailAgent(row)}
className="flex items-center gap-2 group text-left"
title="Click to view agent details"
>
<span className="font-mono text-xs text-blue-400 group-hover:text-blue-300 transition-colors">{row.uuid || row.serial}</span>
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
</button>
)
},
{
header: "Label",
accessor: (row) => {
const friendlyLabel = AGENT_LABELS[row.uuid || row.serial];
return (
<button onClick={() => setDetailAgent(row)} className="text-left hover:text-white transition-colors">
{friendlyLabel || row.label || <span className="text-muted-foreground italic">No label</span>}
</button>
);
}
},
{ header: "Provisioned", accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> },
{ header: "Activated", accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge> },
{ header: "Last Seen", accessor: (row) => <span className="text-muted-foreground">{row.last_seen_at?.human || '-'}</span> },
@@ -109,10 +142,17 @@ export default function AgentsPage() {
header: "Actions",
accessor: (row) => (
<div className="flex items-center gap-2">
<button onClick={() => openEdit(row)} className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded transition-colors">
<button
onClick={() => setDetailAgent(row)}
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors"
title="View details"
>
<ChevronRight className="w-4 h-4" />
</button>
<button onClick={(e) => { e.stopPropagation(); openEdit(row); }} className="p-1.5 text-slate-400 hover:text-white hover:bg-slate-800 rounded transition-colors">
<Edit className="w-4 h-4" />
</button>
<button onClick={() => openDelete(row)} className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors">
<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">
<Trash2 className="w-4 h-4" />
</button>
</div>
@@ -143,8 +183,24 @@ export default function AgentsPage() {
</CardHeader>
<CardContent>
{error ? (
<div className="p-4 bg-red-950/30 border border-red-900/50 rounded-lg text-red-400 text-sm">
{error}
<div className="p-6 bg-red-950/20 border border-red-500/30 rounded-xl space-y-4 text-slate-300">
<div className="flex items-center gap-2 text-red-400 font-semibold">
<XCircle className="w-5 h-5" />
<span>Akses Agents Ditolak (403 Forbidden)</span>
</div>
<p className="text-sm leading-relaxed">
API Key yang Anda gunakan saat ini (<code className="bg-red-950/40 text-red-300 px-1.5 py-0.5 rounded font-mono text-xs">sk_db_live_...</code>) adalah <strong>Site-scoped API Key</strong>. Netify membatasi kunci ini agar hanya dapat membaca data statistik jaringan (Informatics), sehingga tidak memiliki izin untuk mengelola inventaris Agents global.
</p>
<div className="bg-slate-900/60 rounded-lg p-4 border border-border/40 text-xs space-y-2 leading-relaxed">
<p className="font-semibold text-white">💡 Cara Mengatasinya:</p>
<ol className="list-decimal pl-4 space-y-1 text-slate-400">
<li>Buka dan login ke portal <a href="https://portal.netify.ai" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline font-medium">Netify Portal</a>.</li>
<li>Masuk ke menu <strong>Account</strong> di bagian atas.</li>
<li>Pilih <strong>Profile &gt; API Access</strong> pada menu sebelah kiri.</li>
<li>Buat sebuah <strong>Account-level API Key</strong> (kunci ini akan memiliki akses menyeluruh tanpa kedaluwarsa).</li>
<li>Buka file <code className="text-slate-200">.env.local</code> di proyek Anda, lalu ganti nilai <code className="text-slate-200">NETIFY_API_KEY</code> dengan kunci baru tersebut.</li>
</ol>
</div>
</div>
) : (
<DataTable
@@ -245,6 +301,15 @@ export default function AgentsPage() {
</div>
</Modal>
{/* Agent Detail Modal */}
{detailAgent && (
<AgentDetailModal
agentUuid={detailAgent.uuid || detailAgent.serial}
agentLabel={AGENT_LABELS[detailAgent.uuid || detailAgent.serial] || detailAgent.label || detailAgent.uuid || detailAgent.serial}
onClose={() => setDetailAgent(null)}
/>
)}
</div>
);
}
+54 -32
View File
@@ -1,16 +1,18 @@
"use client";
import { useState, useEffect } from "react";
import { useTopApps } from "@/lib/api";
import { useTopApps, AppStat } from "@/lib/api";
import { DataTable, Column } from "@/components/ui/DataTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Loader2 } from "lucide-react";
import { AppDetailModal } from "@/components/ui/AppDetailModal";
import { Loader2, ChevronRight } from "lucide-react";
import { fmtBytes } from "@/lib/utils";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
export default function AppsPage() {
const [mounted, setMounted] = useState(false);
const { data, isLoading } = useTopApps(50); // fetch 50 apps
const [selectedApp, setSelectedApp] = useState<AppStat | null>(null);
useEffect(() => {
setMounted(true);
@@ -20,41 +22,51 @@ export default function AppsPage() {
const top5 = data.slice(0, 5);
const columns: Column<typeof data[0]>[] = [
const columns: Column<AppStat>[] = [
{ header: "#", accessor: (row, i) => i + 1 },
{
header: "Application",
{
header: "Application",
accessor: (row) => (
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedApp(row)}
className="flex items-center gap-2 group text-left"
title="Click to view app details"
>
{row.favicon && (
<img src={row.favicon} alt={row.app_label} className="w-5 h-5 rounded-sm bg-white p-0.5" />
<img src={row.favicon} alt={row.app_label} className="w-5 h-5 rounded-sm bg-white p-0.5 flex-shrink-0" />
)}
<span>{row.app_label}</span>
</div>
)
<span className="group-hover:text-blue-300 transition-colors">{row.app_label}</span>
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
</button>
),
},
{ header: "Category", accessor: (row) => row.category || "-" },
{
header: "Download",
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>
{
header: "Download",
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>,
},
{
header: "Upload",
accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>
{
header: "Upload",
accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>,
},
{
header: "Total",
accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span>,
},
{
header: "Total",
accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span>
}
];
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">
Applications
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
</h2>
<div>
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
Applications
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
</h2>
<p className="text-muted-foreground mt-1 text-sm">
Click any application to see which agents and devices are using it.
</p>
</div>
</div>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
@@ -67,9 +79,9 @@ export default function AppsPage() {
<BarChart data={top5} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" vertical={false} />
<XAxis dataKey="app_label" stroke="#8b949e" tickLine={false} axisLine={false} />
<YAxis
stroke="#8b949e"
tickLine={false}
<YAxis
stroke="#8b949e"
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const gb = value / 1073741824;
@@ -77,10 +89,10 @@ export default function AppsPage() {
return gb >= 1 ? `${gb.toFixed(1)}GB` : `${mb.toFixed(0)}MB`;
}}
/>
<RechartsTooltip
cursor={{fill: '#21262d'}}
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
formatter={(value: any) => [fmtBytes(value as number), 'Volume']}
<RechartsTooltip
cursor={{ fill: "#21262d" }}
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
/>
<Bar dataKey="download" name="Download" fill="#58a6ff" radius={[4, 4, 0, 0]} />
<Bar dataKey="upload" name="Upload" fill="#3fb950" radius={[4, 4, 0, 0]} />
@@ -89,7 +101,7 @@ export default function AppsPage() {
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="text-white">All Applications ({data.length})</CardTitle>
@@ -109,6 +121,16 @@ export default function AppsPage() {
/>
</CardContent>
</Card>
{/* App Detail Modal */}
{selectedApp && (
<AppDetailModal
appLabel={selectedApp.app_label}
favicon={selectedApp.favicon}
category={selectedApp.category}
onClose={() => setSelectedApp(null)}
/>
)}
</div>
);
}
+69 -18
View File
@@ -2,14 +2,16 @@
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 { useDevices, DeviceStat } from "@/lib/api";
import { fmtBytes } from "@/lib/utils";
import { Loader2 } from "lucide-react";
import { Loader2, ChevronRight } from "lucide-react";
export default function DevicesPage() {
const [mounted, setMounted] = useState(false);
const devices = useDevices(100);
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
useEffect(() => {
setMounted(true);
@@ -19,13 +21,45 @@ export default function DevicesPage() {
const columns: Column<DeviceStat>[] = [
{ header: "#", accessor: (row, i) => i + 1 },
{ header: "IP Address", accessor: (row) => <span className="font-medium text-blue-400">{row.ip_address || '-'}</span> },
{ header: "MAC Address", accessor: (row) => row.mac_address ? <code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground">{row.mac_address}</code> : '-' },
{ header: "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: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "text-right" },
{
header: "IP Address",
accessor: (row) => (
<button
onClick={() => setSelectedDevice(row)}
className="flex items-center gap-1.5 group text-left"
title="Click to view device details"
>
<span className="font-medium text-blue-400 group-hover:text-blue-300 transition-colors font-mono text-sm">
{row.ip_address || "-"}
</span>
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
</button>
),
},
{
header: "MAC Address",
accessor: (row) =>
row.mac_address ? (
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground">
{row.mac_address}
</code>
) : (
"-"
),
},
{ header: "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: "Upload",
accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>,
className: "text-right",
},
];
return (
@@ -36,29 +70,46 @@ export default function DevicesPage() {
Devices
{devices.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
</h2>
<p className="text-muted-foreground mt-1">Monitor and manage connected network devices.</p>
<p className="text-muted-foreground mt-1">
Monitor and manage connected network devices.{" "}
<span className="text-slate-500 text-xs">Click an IP to see app usage &amp; flows.</span>
</p>
</div>
</div>
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '100ms' }}>
<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">
<CardTitle className="text-lg text-white">Active Devices {devices.data ? `(${devices.data.length})` : ''}</CardTitle>
<CardTitle className="text-lg text-white">
Active Devices {devices.data ? `(${devices.data.length})` : ""}
</CardTitle>
</CardHeader>
<CardContent>
<DataTable
data={devices.data || []}
columns={columns}
<DataTable
data={devices.data || []}
columns={columns}
searchPlaceholder="Search devices by IP, MAC, OS..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (row.ip_address || "").toLowerCase().includes(q) ||
(row.mac_address || "").toLowerCase().includes(q) ||
(row.os_label || "").toLowerCase().includes(q) ||
(row.device_type || "").toLowerCase().includes(q);
return (
(row.ip_address || "").toLowerCase().includes(q) ||
(row.mac_address || "").toLowerCase().includes(q) ||
(row.os_label || "").toLowerCase().includes(q) ||
(row.device_type || "").toLowerCase().includes(q)
);
}}
/>
</CardContent>
</Card>
{/* Device Detail Modal */}
{selectedDevice && (
<DeviceDetailModal
ip={selectedDevice.ip_address}
deviceLabel={selectedDevice.device_label}
macAddress={selectedDevice.mac_address}
onClose={() => setSelectedDevice(null)}
/>
)}
</div>
);
}
+97 -79
View File
@@ -12,6 +12,7 @@ import {
import { useState, useEffect } from "react";
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols } from "@/lib/api";
import { fmtBytes } from "@/lib/utils";
import Link from "next/link";
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
@@ -44,91 +45,108 @@ export default function SummaryPage() {
{/* KPI Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-primary/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '0ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Download className="h-16 w-16 text-blue-500" />
</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-blue-400 transition-colors">Download</CardTitle>
<Download className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data ? fmtBytes(summary.data.bandwidth_down) : "0 B"}
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl">
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-blue-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '0ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Download className="h-16 w-16 text-blue-500" />
</div>
</CardContent>
</Card>
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '50ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Upload className="h-16 w-16 text-emerald-500" />
</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-emerald-400 transition-colors">Upload</CardTitle>
<Upload className="h-4 w-4 text-emerald-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data ? fmtBytes(summary.data.bandwidth_up) : "0 B"}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-blue-400 transition-colors">Download</CardTitle>
<Download className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data ? fmtBytes(summary.data.bandwidth_down) : "0 B"}
</div>
</CardContent>
</Card>
</Link>
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-emerald-500/50 rounded-xl">
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '50ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Upload className="h-16 w-16 text-emerald-500" />
</div>
</CardContent>
</Card>
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '100ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Server className="h-16 w-16 text-orange-500" />
</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-orange-400 transition-colors">Devices</CardTitle>
<Server className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data?.total_devices?.toLocaleString() || "0"}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-emerald-400 transition-colors">Upload</CardTitle>
<Upload className="h-4 w-4 text-emerald-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data ? fmtBytes(summary.data.bandwidth_up) : "0 B"}
</div>
</CardContent>
</Card>
</Link>
<Link href="/devices" className="block focus:outline-none focus:ring-2 focus:ring-orange-500/50 rounded-xl">
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '100ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Server className="h-16 w-16 text-orange-500" />
</div>
</CardContent>
</Card>
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '150ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Activity className="h-16 w-16 text-purple-500" />
</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-purple-400 transition-colors">Flows</CardTitle>
<Activity className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data?.active_flows?.toLocaleString() || "0"}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-orange-400 transition-colors">Devices</CardTitle>
<Server className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data?.total_devices?.toLocaleString() || "0"}
</div>
</CardContent>
</Card>
</Link>
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-purple-500/50 rounded-xl">
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '150ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Activity className="h-16 w-16 text-purple-500" />
</div>
</CardContent>
</Card>
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-red-500/20 hover:bg-card/100 hover:border-red-500/50 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '200ms' }}>
<div className="absolute inset-0 bg-red-500/5 pointer-events-none" />
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-red-500">
<ShieldAlert className="h-16 w-16" />
</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-red-400 group-hover:text-red-300 transition-colors">Threats</CardTitle>
<ShieldAlert className="h-4 w-4 text-red-500 animate-pulse" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-red-500 tracking-tight">
{summary.data?.total_threats?.toLocaleString() || "0"}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-purple-400 transition-colors">Flows</CardTitle>
<Activity className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data?.active_flows?.toLocaleString() || "0"}
</div>
</CardContent>
</Card>
</Link>
<Link href="/threats" className="block focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl">
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-red-500/20 hover:bg-card/100 hover:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
<div className="absolute inset-0 bg-red-500/5 pointer-events-none" />
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-red-500">
<ShieldAlert className="h-16 w-16" />
</div>
</CardContent>
</Card>
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 transition-all duration-300 cursor-default animate-slide-up" style={{ animationDelay: '250ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-yellow-500">
<Zap className="h-16 w-16" />
</div>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-yellow-400 transition-colors">Events</CardTitle>
<Zap className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data?.total_events?.toLocaleString() || "0"}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-red-400 group-hover:text-red-300 transition-colors">Threats</CardTitle>
<ShieldAlert className="h-4 w-4 text-red-500 animate-pulse" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-red-500 tracking-tight">
{summary.data?.total_threats?.toLocaleString() || "0"}
</div>
</CardContent>
</Card>
</Link>
<Link href="/events" className="block focus:outline-none focus:ring-2 focus:ring-yellow-500/50 rounded-xl">
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '250ms' }}>
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-yellow-500">
<Zap className="h-16 w-16" />
</div>
</CardContent>
</Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-yellow-400 transition-colors">Events</CardTitle>
<Zap className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent className="relative z-10">
<div className="text-3xl font-bold text-white tracking-tight">
{summary.data?.total_events?.toLocaleString() || "0"}
</div>
</CardContent>
</Card>
</Link>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7 animate-slide-up" style={{ animationDelay: '300ms' }}>
+214 -27
View File
@@ -1,40 +1,69 @@
"use client";
import { useState, useEffect } from "react";
import { useTlsVersions, useTlsCiphers, useTlsSecurity } from "@/lib/api";
import {
useTlsVersions,
useTlsCiphers,
useTlsSecurity,
fetchSecurityDevices,
SecurityDevice,
} from "@/lib/api";
import { DataTable, Column } from "@/components/ui/DataTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Loader2, ShieldCheck, ShieldAlert, ShieldX } from "lucide-react";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { Loader2, ShieldCheck, ShieldAlert, ShieldX, AlertTriangle, CheckCircle2, Info } from "lucide-react";
import { fmtBytes } from "@/lib/utils";
import { PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from "recharts";
import {
PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
} from "recharts";
const COLORS = {
Recommended: "#3fb950",
Secure: "#58a6ff",
Weak: "#f0883e",
Insecure: "#f85149"
Insecure: "#f85149",
};
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" },
} as const;
export default function SecurityAuditPage() {
const [mounted, setMounted] = useState(false);
const versions = useTlsVersions(10);
const ciphers = useTlsCiphers(20);
const security = useTlsSecurity(10);
const [secDevices, setSecDevices] = useState<SecurityDevice[]>([]);
const [secDevicesLoading, setSecDevicesLoading] = useState(true);
const [selectedDevice, setSelectedDevice] = useState<SecurityDevice | null>(null);
useEffect(() => {
setMounted(true);
fetchSecurityDevices()
.then(setSecDevices)
.catch(() => setSecDevices([]))
.finally(() => setSecDevicesLoading(false));
}, []);
if (!mounted) return null;
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 versionColumns: Column<typeof versions.data[0]>[] = [
{ header: "#", accessor: (row, i) => i + 1 },
{ header: "TLS Version", accessor: (row) => row.tls_version },
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span> },
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span> },
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> }
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> },
];
const cipherColumns: Column<typeof ciphers.data[0]>[] = [
@@ -42,16 +71,95 @@ export default function SecurityAuditPage() {
{ header: "Cipher Suite", accessor: (row) => <span className="text-purple-400">{row.tls_cipher}</span> },
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span> },
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span> },
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> }
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span> },
];
const secDeviceColumns: Column<SecurityDevice>[] = [
{ header: "#", accessor: (_, i) => i + 1 },
{
header: "IP Address",
accessor: (row) => (
<button
onClick={() => setSelectedDevice(row)}
className="font-mono text-sm text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1 group"
>
{row.ip_address}
</button>
),
},
{
header: "Device",
accessor: (row) => (
<div className="flex flex-col">
<span className="text-sm text-white">{row.device_label || row.ip_address}</span>
{row.device_type && <span className="text-xs text-slate-400">{row.device_type}</span>}
</div>
),
},
{
header: "OS / Manufacturer",
accessor: (row) => (
<div className="flex flex-col">
<span className="text-xs text-blue-400">{row.os_label || "-"}</span>
<span className="text-xs text-slate-500">{row.manufacturer || ""}</span>
</div>
),
},
{
header: "Encrypted %",
accessor: (row) => {
const pct = row.encrypted_pct ?? 0;
const color = pct >= 80 ? "text-green-400" : pct >= 50 ? "text-orange-400" : "text-red-400";
const bar = pct >= 80 ? "from-green-500 to-emerald-400" : pct >= 50 ? "from-orange-500 to-yellow-400" : "from-red-500 to-pink-400";
return (
<div className="flex flex-col gap-1 min-w-[100px]">
<span className={`text-sm font-semibold ${color}`}>{pct.toFixed(1)}%</span>
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden w-24">
<div className={`h-full bg-gradient-to-r ${bar} rounded-full`} style={{ width: `${pct}%` }} />
</div>
</div>
);
},
},
{
header: "Traffic",
accessor: (row) => (
<div className="text-xs text-slate-400">
<span className="text-cyan-400">{fmtBytes(row.encrypted)}</span>
<span className="text-slate-600"> / </span>
<span className="text-red-400">{fmtBytes(row.unencrypted)}</span>
</div>
),
},
{
header: "Risk Level",
accessor: (row) => {
const cfg = RISK_CONFIG[row.risk_level] ?? RISK_CONFIG.Aman;
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold ring-1 ${cfg.bg} ${cfg.color}`}>
{cfg.icon}
{row.risk_level}
{row.has_insecure && (
<span title="Uses insecure protocol">
<AlertTriangle className="w-3 h-3 text-yellow-400 ml-0.5" />
</span>
)}
</span>
);
},
},
];
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
Security Audit (TLS)
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
</h2>
<div>
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
Security Audit
{(isLoading || secDevicesLoading) && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
</h2>
<p className="text-muted-foreground mt-1 text-sm">TLS encryption analysis and device-level risk assessment.</p>
</div>
</div>
{/* Security Overview Cards */}
@@ -68,14 +176,83 @@ export default function SecurityAuditPage() {
{item.tls_security === "Insecure" && <ShieldX className="h-4 w-4 text-red-500" />}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">
{fmtBytes(item.total)}
</div>
<div className="text-2xl font-bold text-white">{fmtBytes(item.total)}</div>
</CardContent>
</Card>
))}
</div>
{/* Device Risk Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card className="bg-red-950/20 border-red-500/20 backdrop-blur-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-red-300">Rawan (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>
</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>
<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>
</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>
<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>
</CardContent>
</Card>
</div>
{/* Device Risk Table */}
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
Device Risk Assessment
{secDevicesLoading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<span className="ml-auto text-xs text-slate-400 font-normal flex items-center gap-1">
<Info className="w-3 h-3" />
Based on encryption audit data. Click IP to inspect device.
</span>
</CardTitle>
</CardHeader>
<CardContent>
{secDevices.length === 0 && !secDevicesLoading ? (
<div className="text-center py-10 text-slate-500 text-sm">
No encryption audit data available yet. The scheduler will populate this table automatically.
</div>
) : (
<DataTable
data={secDevices}
columns={secDeviceColumns}
searchPlaceholder="Search by IP, device type, OS..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.ip_address || "").toLowerCase().includes(q) ||
(row.device_label || "").toLowerCase().includes(q) ||
(row.device_type || "").toLowerCase().includes(q) ||
(row.os_label || "").toLowerCase().includes(q) ||
(row.risk_level || "").toLowerCase().includes(q)
);
}}
/>
)}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{/* TLS Security Pie Chart */}
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
@@ -100,18 +277,18 @@ export default function SecurityAuditPage() {
<Cell key={`cell-${index}`} fill={COLORS[entry.tls_security as keyof typeof COLORS] || "#8884d8"} />
))}
</Pie>
<RechartsTooltip
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
formatter={(value: any) => [fmtBytes(value as number), 'Volume']}
<RechartsTooltip
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
/>
</PieChart>
</ResponsiveContainer>
<div className="flex flex-col justify-center ml-4 space-y-2">
{security.data.map((entry, index) => (
<div key={`${entry.tls_security}-${index}`} className="flex items-center text-xs text-muted-foreground whitespace-nowrap">
<div
className="w-3 h-3 rounded-full mr-2 shrink-0"
style={{ backgroundColor: COLORS[entry.tls_security as keyof typeof COLORS] || "#8884d8" }}
<div
className="w-3 h-3 rounded-full mr-2 shrink-0"
style={{ backgroundColor: COLORS[entry.tls_security as keyof typeof COLORS] || "#8884d8" }}
/>
<span>{entry.tls_security}</span>
</div>
@@ -132,10 +309,10 @@ export default function SecurityAuditPage() {
<BarChart data={versions.data} margin={{ top: 10, right: 10, left: 20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" vertical={false} />
<XAxis dataKey="tls_version" stroke="#8b949e" fontSize={10} tickLine={false} axisLine={false} />
<YAxis
stroke="#8b949e"
fontSize={10}
tickLine={false}
<YAxis
stroke="#8b949e"
fontSize={10}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const gb = value / 1073741824;
@@ -143,10 +320,10 @@ export default function SecurityAuditPage() {
return gb >= 1 ? `${gb.toFixed(1)}GB` : `${mb.toFixed(0)}MB`;
}}
/>
<RechartsTooltip
cursor={{fill: '#21262d'}}
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
formatter={(value: any) => [fmtBytes(value as number), 'Volume']}
<RechartsTooltip
cursor={{ fill: "#21262d" }}
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
/>
<Bar dataKey="total" fill="#bc8cff" radius={[4, 4, 0, 0]} />
</BarChart>
@@ -187,6 +364,16 @@ export default function SecurityAuditPage() {
</CardContent>
</Card>
</div>
{/* Device Detail Modal */}
{selectedDevice && (
<DeviceDetailModal
ip={selectedDevice.ip_address}
deviceLabel={selectedDevice.device_label}
macAddress={selectedDevice.mac_address}
onClose={() => setSelectedDevice(null)}
/>
)}
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useState, useEffect } from "react";
import { fetchAgentDetails, AgentDetails } from "@/lib/api";
import { fmtBytes } from "@/lib/utils";
import { Loader2, X, Server, Monitor, Activity, Globe, ChevronRight } from "lucide-react";
interface Props {
agentUuid: string;
agentLabel: string;
onClose: () => void;
}
type Tab = "devices" | "flows" | "apps";
export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
const [data, setData] = useState<AgentDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>("devices");
useEffect(() => {
setIsLoading(true);
setError(null);
fetchAgentDetails(agentUuid)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setIsLoading(false));
}, [agentUuid]);
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number }[] = [
{ key: "devices", label: "Devices", icon: <Monitor className="w-4 h-4" />, count: data?.devices.length },
{ key: "flows", label: "Active Flows", icon: <Activity className="w-4 h-4" />, count: data?.flows.length },
{ key: "apps", label: "Top Apps", icon: <Globe className="w-4 h-4" />, count: data?.top_apps.length },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div
className="relative bg-[#0d1117] border border-white/10 rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl shadow-black/60 animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-gradient-to-r from-blue-900/30 to-purple-900/20 rounded-t-2xl">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/20 rounded-lg ring-1 ring-blue-500/30">
<Server className="w-5 h-5 text-blue-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">{agentLabel}</h3>
<p className="text-xs text-slate-400 font-mono">{agentUuid}</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{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-slate-400 text-sm">Loading agent data...</p>
</div>
) : error ? (
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
) : data ? (
<>
{/* KPI Summary row */}
<div className="grid grid-cols-4 gap-4 px-6 py-4 border-b border-white/5">
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
<p className="text-xs text-slate-400 mb-1">Devices</p>
<p className="text-2xl font-bold text-white">{data.devices.length}</p>
</div>
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
<p className="text-xs text-slate-400 mb-1">Active Flows</p>
<p className="text-2xl font-bold text-white">{data.summary?.active_flows ?? data.flows.length}</p>
</div>
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
<p className="text-xs text-slate-400 mb-1">Total Download</p>
<p className="text-2xl font-bold text-cyan-400">{fmtBytes(data.summary?.bandwidth_down ?? 0)}</p>
</div>
<div className="bg-white/5 rounded-xl p-4 border border-white/5">
<p className="text-xs text-slate-400 mb-1">Total Upload</p>
<p className="text-2xl font-bold text-green-400">{fmtBytes(data.summary?.bandwidth_up ?? 0)}</p>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 px-6 py-3 border-b border-white/5">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === tab.key
? "bg-blue-500/20 text-blue-300 ring-1 ring-blue-500/40"
: "text-slate-400 hover:text-white hover:bg-white/5"
}`}
>
{tab.icon}
{tab.label}
{tab.count !== undefined && (
<span className="ml-1 px-1.5 py-0.5 bg-white/10 rounded text-xs text-slate-300">
{tab.count}
</span>
)}
</button>
))}
</div>
{/* Tab content */}
<div className="px-6 py-4">
{activeTab === "devices" && (
<div className="space-y-2">
{data.devices.length === 0 ? (
<p className="text-slate-500 text-sm text-center py-8">No devices found for this agent.</p>
) : (
data.devices.map((d, i) => {
const riskCfg = d.risk_level === "Rawan" || d.risk_level === "Critical"
? { color: "text-red-400", bg: "bg-red-500/10 ring-red-500/30", dot: "bg-red-400", label: d.risk_level }
: d.risk_level === "Sedang" || d.risk_level === "Medium"
? { color: "text-yellow-400", bg: "bg-yellow-500/10 ring-yellow-500/30", dot: "bg-yellow-400", label: d.risk_level }
: d.risk_level === "Aman" || d.risk_level === "Low"
? { color: "text-green-400", bg: "bg-green-500/10 ring-green-500/30", dot: "bg-green-400", label: d.risk_level }
: null;
return (
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-lg transition-colors">
<div className="flex items-center gap-3">
<div className="flex-shrink-0 p-2 bg-slate-800/60 rounded-lg">
<Monitor className="w-4 h-4 text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-white text-sm truncate">{d.device_label || d.ip_address || "Unknown"}</p>
{riskCfg && (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 ${riskCfg.bg} ${riskCfg.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${riskCfg.dot}`} />
{riskCfg.label}
</span>
)}
{d.has_insecure && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 bg-orange-500/10 ring-orange-500/30 text-orange-400">
⚠ Insecure
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<p className="text-xs text-slate-400 font-mono">{d.ip_address}</p>
{d.os_label && <p className="text-xs text-blue-400">{d.os_label}</p>}
{d.manufacturer && <p className="text-xs text-slate-500">{d.manufacturer}</p>}
</div>
</div>
<div className="text-right flex-shrink-0 space-y-0.5">
<p className="text-xs text-cyan-400">{fmtBytes(d.download ?? 0)}</p>
{d.encrypted_pct !== null && (
<p className="text-xs text-slate-500">🔒 {d.encrypted_pct}%</p>
)}
</div>
</div>
</div>
);
})
)}
</div>
)}
{activeTab === "flows" && (
<div className="overflow-x-auto">
{data.flows.length === 0 ? (
<p className="text-slate-500 text-sm text-center py-8">No flows found for this agent.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b border-white/10">
<th className="pb-2 text-xs font-medium text-slate-400">Source IP</th>
<th className="pb-2 text-xs font-medium text-slate-400">Destination</th>
<th className="pb-2 text-xs font-medium text-slate-400">App / Domain</th>
<th className="pb-2 text-xs font-medium text-slate-400">Protocol</th>
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Download</th>
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Upload</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{data.flows.map((f, i) => (
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
<td className="py-2 pr-4 font-mono text-xs text-blue-400">{f.src_ip || "-"}</td>
<td className="py-2 pr-4 font-mono text-xs text-slate-300">
{f.dst_ip || "-"}
{f.dst_port ? <span className="text-slate-500">:{f.dst_port}</span> : ""}
</td>
<td className="py-2 pr-4 text-xs text-purple-300 max-w-[140px] truncate">
{f.app_label || f.domain || "-"}
</td>
<td className="py-2 pr-4 text-xs text-slate-400">{f.protocol || "-"}</td>
<td className="py-2 pr-4 text-right text-xs text-cyan-400">{fmtBytes(f.download)}</td>
<td className="py-2 text-right text-xs text-green-400">{fmtBytes(f.upload)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{activeTab === "apps" && (
<div className="space-y-2">
{data.top_apps.length === 0 ? (
<p className="text-slate-500 text-sm text-center py-8">No application data for this agent.</p>
) : (
data.top_apps.map((a, i) => {
const total = a.download + a.upload;
const maxTotal = Math.max(...data.top_apps.map(x => x.download + x.upload), 1);
const pct = (total / maxTotal) * 100;
return (
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-lg transition-colors">
<div className="flex items-center gap-3 mb-2">
{a.favicon ? (
<img src={a.favicon} alt={a.app_label} className="w-5 h-5 rounded bg-white p-0.5 flex-shrink-0" />
) : (
<div className="w-5 h-5 rounded bg-slate-700 flex-shrink-0" />
)}
<span className="font-medium text-white text-sm flex-1">{a.app_label}</span>
{a.category && <span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded">{a.category}</span>}
<span className="text-xs font-mono text-white">{fmtBytes(total)}</span>
</div>
<div className="flex gap-2 text-xs text-slate-400">
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
<span className="text-green-400">↑ {fmtBytes(a.upload)}</span>
</div>
<div className="mt-2 h-1 bg-white/5 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})
)}
</div>
)}
</div>
</>
) : null}
</div>
</div>
</div>
);
}
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useState, useEffect } from "react";
import { fetchAppDetails, AppDetails } from "@/lib/api";
import { fmtBytes } from "@/lib/utils";
import { Loader2, X, Globe, Server, Monitor } from "lucide-react";
interface Props {
appLabel: string;
favicon?: string | null;
category?: string | null;
onClose: () => void;
}
export function AppDetailModal({ appLabel, favicon, category, onClose }: Props) {
const [data, setData] = useState<AppDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
fetchAppDetails(appLabel)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setIsLoading(false));
}, [appLabel]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div
className="relative bg-[#0d1117] border border-white/10 rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-2xl shadow-black/60 animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-gradient-to-r from-green-900/30 to-emerald-900/20 rounded-t-2xl">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-500/20 rounded-lg ring-1 ring-green-500/30">
{favicon ? (
<img src={favicon} alt={appLabel} className="w-5 h-5 rounded" />
) : (
<Globe className="w-5 h-5 text-green-400" />
)}
</div>
<div>
<h3 className="text-lg font-bold text-white">{appLabel}</h3>
{category && <p className="text-xs text-slate-400">{category}</p>}
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<Loader2 className="w-8 h-8 animate-spin text-green-400" />
<p className="text-slate-400 text-sm">Loading application data...</p>
</div>
) : error ? (
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
) : data ? (
<div className="p-6 space-y-6">
{/* Total Summary */}
<div className="grid grid-cols-2 gap-3 p-4 bg-white/[0.03] border border-white/5 rounded-xl">
<div className="text-center">
<p className="text-xs text-slate-500">Total Download</p>
<p className="text-xl font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-500">Total Upload</p>
<p className="text-xl font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
</div>
</div>
{/* Per-Agent Breakdown */}
<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
</h4>
{data.agent_scorecard.length === 0 ? (
<p className="text-slate-500 text-sm text-center py-4">No traffic from known agents for this app yet.</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-white/5 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-white">{a.agent_label}</span>
{a.agent_uuid && (
<span className="text-xs font-mono text-slate-500">{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-white/5 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" />
{data.has_domain_breakdown ? "Top Devices (via Domain Match)" : "Top Devices / IPs"}
{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-slate-500 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-2 text-xs font-medium text-slate-400">#</th>
<th className="pb-2 text-xs font-medium text-slate-400">IP Address</th>
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Download</th>
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Upload</th>
<th className="pb-2 text-xs font-medium text-slate-400">Usage</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.03] transition-colors">
<td className="py-2 pr-4 text-xs text-slate-500">{i + 1}</td>
<td className="py-2 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-slate-400 bg-white/5 px-1.5 py-0.5 rounded">{ip.reputation}</span>
)}
</div>
</td>
<td className="py-2 pr-4 text-right text-xs text-cyan-400">{fmtBytes(ip.download)}</td>
<td className="py-2 pr-4 text-right text-xs text-green-400">{fmtBytes(ip.upload ?? 0)}</td>
<td className="py-2 w-32">
<div className="h-1.5 bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${ip.blacklisted ? "bg-red-500" : "bg-gradient-to-r from-cyan-500 to-green-500"}`}
style={{ width: `${pct}%` }}
/>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
) : null}
</div>
</div>
</div>
);
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useState, useEffect } from "react";
import { fetchDeviceDetails, DeviceDetails } from "@/lib/api";
import { fmtBytes } from "@/lib/utils";
import { Loader2, X, Monitor, Globe, Activity } from "lucide-react";
interface Props {
ip: string;
deviceLabel?: string | null;
macAddress?: string | null;
onClose: () => void;
}
type Tab = "apps" | "flows";
export function DeviceDetailModal({ ip, deviceLabel, macAddress, onClose }: Props) {
const [data, setData] = useState<DeviceDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<Tab>("apps");
useEffect(() => {
setIsLoading(true);
setError(null);
fetchDeviceDetails(ip)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setIsLoading(false));
}, [ip]);
const tabs: { key: Tab; label: string; icon: React.ReactNode; count?: number }[] = [
{ key: "apps", label: "Applications", icon: <Globe className="w-4 h-4" />, count: data?.top_apps.length },
{ key: "flows", label: "Active Flows", icon: <Activity className="w-4 h-4" />, count: data?.flows.length },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" />
<div
className="relative bg-[#0d1117] border border-white/10 rounded-2xl w-full max-w-3xl max-h-[85vh] flex flex-col shadow-2xl shadow-black/60 animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-gradient-to-r from-cyan-900/30 to-blue-900/20 rounded-t-2xl">
<div className="flex items-center gap-3">
<div className="p-2 bg-cyan-500/20 rounded-lg ring-1 ring-cyan-500/30">
<Monitor className="w-5 h-5 text-cyan-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">{deviceLabel || ip}</h3>
<div className="flex items-center gap-3 mt-0.5">
<p className="text-xs text-blue-400 font-mono">{ip}</p>
{macAddress && <p className="text-xs text-slate-400 font-mono">{macAddress}</p>}
</div>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<Loader2 className="w-8 h-8 animate-spin text-cyan-400" />
<p className="text-slate-400 text-sm">Loading device data...</p>
</div>
) : error ? (
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
) : data ? (
<>
{/* KPI Summary */}
<div className="grid grid-cols-3 gap-3 px-6 py-3 border-b border-white/5 bg-white/[0.02]">
<div className="text-center">
<p className="text-xs text-slate-500">Total Download</p>
<p className="text-lg font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-500">Total Upload</p>
<p className="text-lg font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
</div>
<div className="text-center">
<p className="text-xs text-slate-500">Total Flows</p>
<p className="text-lg font-bold text-white">{data.flow_count ?? data.flows.length}</p>
</div>
</div>
{/* Security Info Panel */}
{data.security_info && (data.security_info.risk_level || data.security_info.os_label || data.security_info.device_type || data.security_info.insecure_protocols.length > 0 || data.security_info.blacklisted) && (() => {
const si = data.security_info!;
const riskColor = si.risk_level === "Rawan" || si.risk_level === "Critical" ? "text-red-400 bg-red-500/10 ring-red-500/30"
: si.risk_level === "Sedang" || si.risk_level === "Medium" ? "text-yellow-400 bg-yellow-500/10 ring-yellow-500/30"
: "text-green-400 bg-green-500/10 ring-green-500/30";
const encPct = si.encrypted_pct ?? 0;
return (
<div className="mx-6 my-3 p-4 bg-white/[0.02] border border-white/8 rounded-xl space-y-3">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Security Info</p>
{/* Row 1: Device Identity */}
{(si.device_type || si.os_label || si.manufacturer) && (
<div className="flex items-center gap-3 flex-wrap">
{si.device_type && <span className="text-xs text-slate-300 bg-white/5 px-2 py-0.5 rounded">{si.device_type}</span>}
{si.os_label && <span className="text-xs text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded">{si.os_label}</span>}
{si.manufacturer && <span className="text-xs text-slate-400">{si.manufacturer}</span>}
</div>
)}
{/* Row 2: Encryption bar */}
{si.encrypted_pct !== null && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-slate-400">Enkripsi Traffic</span>
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ring-1 ${riskColor}`}>
{si.risk_level}
</span>
<span className="text-xs text-white font-mono">{encPct}%</span>
</div>
</div>
<div className="h-2 bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${encPct >= 80 ? "bg-green-500" : encPct >= 50 ? "bg-yellow-500" : "bg-red-500"}`}
style={{ width: `${encPct}%` }}
/>
</div>
<div className="flex justify-between mt-1 text-xs text-slate-500">
<span>Terenkripsi: {fmtBytes(si.encrypted_bytes ?? 0)}</span>
<span>Tidak: {fmtBytes(si.unencrypted_bytes ?? 0)}</span>
</div>
</div>
)}
{/* Row 3: Warnings */}
<div className="flex items-center gap-2 flex-wrap">
{si.insecure_protocols.length > 0 && si.insecure_protocols.map((p, i) => (
<span key={i} className="inline-flex items-center gap-1 text-xs text-orange-300 bg-orange-500/10 px-2 py-0.5 rounded-full ring-1 ring-orange-500/30">
⚠ {p.protocol} ({p.risk})
</span>
))}
{si.blacklisted && (
<span className="inline-flex items-center gap-1 text-xs text-red-300 bg-red-500/10 px-2 py-0.5 rounded-full ring-1 ring-red-500/30">
🚫 Blacklisted
</span>
)}
{si.reputation && si.reputation !== "Unknown" && !si.blacklisted && (
<span className="inline-flex items-center gap-1 text-xs text-slate-300 bg-white/5 px-2 py-0.5 rounded-full">
Reputasi: {si.reputation}
</span>
)}
</div>
</div>
);
})()}
{/* Tabs */}
<div className="flex gap-1 px-6 py-3 border-b border-white/5">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === tab.key
? "bg-cyan-500/20 text-cyan-300 ring-1 ring-cyan-500/40"
: "text-slate-400 hover:text-white hover:bg-white/5"
}`}
>
{tab.icon}
{tab.label}
{tab.count !== undefined && (
<span className="ml-1 px-1.5 py-0.5 bg-white/10 rounded text-xs text-slate-300">
{tab.count}
</span>
)}
</button>
))}
</div>
<div className="px-6 py-4">
{activeTab === "apps" && (
<div className="space-y-2">
{data.top_apps.length === 0 ? (
<p className="text-slate-500 text-sm text-center py-8">No application data for this device. It may not have active flows yet.</p>
) : (
data.top_apps.map((a, i) => {
const total = a.download + a.upload;
const maxTotal = Math.max(...data.top_apps.map(x => x.download + x.upload), 1);
const pct = (total / maxTotal) * 100;
return (
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 rounded-lg transition-colors">
<div className="flex items-center gap-3 mb-2">
{a.favicon ? (
<img src={a.favicon} alt={a.app_label} className="w-5 h-5 rounded bg-white p-0.5 flex-shrink-0" />
) : (
<div className="w-5 h-5 rounded bg-slate-700 flex-shrink-0" />
)}
<span className="font-medium text-white text-sm flex-1">{a.app_label}</span>
{a.category && (
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded">{a.category}</span>
)}
<span className="text-xs font-mono text-white">{fmtBytes(total)}</span>
</div>
<div className="flex gap-3 text-xs mb-2">
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
<span className="text-green-400">↑ {fmtBytes(a.upload)}</span>
</div>
<div className="h-1 bg-white/5 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})
)}
</div>
)}
{activeTab === "flows" && (
<div className="overflow-x-auto">
{data.flows.length === 0 ? (
<p className="text-slate-500 text-sm text-center py-8">No active flows for this device.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b border-white/10">
<th className="pb-2 text-xs font-medium text-slate-400">Destination IP</th>
<th className="pb-2 text-xs font-medium text-slate-400">Port</th>
<th className="pb-2 text-xs font-medium text-slate-400">App / Domain</th>
<th className="pb-2 text-xs font-medium text-slate-400">Protocol</th>
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Download</th>
<th className="pb-2 text-xs font-medium text-slate-400 text-right">Upload</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{data.flows.map((f, i) => (
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
<td className="py-2 pr-4 font-mono text-xs text-blue-400">{f.dst_ip || "-"}</td>
<td className="py-2 pr-4 text-xs text-slate-400">{f.dst_port ?? "-"}</td>
<td className="py-2 pr-4 text-xs text-purple-300 max-w-[140px] truncate">
{(f as any).app_label || (f as any).domain || "-"}
</td>
<td className="py-2 pr-4 text-xs text-slate-300">{f.protocol || "-"}</td>
<td className="py-2 pr-4 text-right text-xs text-cyan-400">{fmtBytes(f.download)}</td>
<td className="py-2 text-right text-xs text-green-400">{fmtBytes(f.upload)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
</>
) : null}
</div>
</div>
</div>
);
}
+92 -5
View File
@@ -18,17 +18,21 @@ const getHeaders = () => {
"Content-Type": "application/json",
};
// JWT Token access requires x-net-site
if (JWT_TOKEN && SITE_UUID) {
headers["x-api-key"] = JWT_TOKEN;
headers["x-net-site"] = SITE_UUID;
} else if (API_KEY) {
// Prioritize long-term API Key as recommended by Netify support team
if (API_KEY) {
headers["x-api-key"] = API_KEY;
} else if (JWT_TOKEN) {
headers["x-api-key"] = JWT_TOKEN;
}
if (SITE_UUID) {
headers["x-net-site"] = SITE_UUID;
}
return headers;
};
export interface Agent {
id: number;
uuid: string;
@@ -45,6 +49,67 @@ export interface Agent {
export async function getAgents(): Promise<Agent[]> {
try {
const API_KEY = process.env.NETIFY_API_KEY;
const SITE_UUID = process.env.NETIFY_SITE_UUID;
// Fallback to Informatics Scorecard Agent if site-scoped API key is used
if (API_KEY && API_KEY.startsWith("sk_db_")) {
const headers = getHeaders();
const [dlRes, ulRes] = await Promise.all([
fetch('https://informatics.netify.ai/api/v1/data/stats/scorecard/agent/download', { headers, cache: "no-store" }),
fetch('https://informatics.netify.ai/api/v1/data/stats/scorecard/agent/upload', { headers, cache: "no-store" })
]);
if (!dlRes.ok) {
throw new Error(`Failed to fetch agents scorecard: ${dlRes.statusText} (${dlRes.status})`);
}
const dlResult = await dlRes.json();
const ulResult = ulRes.ok ? await ulRes.json() : { data: [] };
const ulMap: Record<string, number> = {};
if (ulResult.data) {
for (const item of ulResult.data) {
if (item.agent?.uuid) {
ulMap[item.agent.uuid] = item.upload ?? 0;
}
}
}
const AGENT_LABELS: Record<string, string> = {
"2F-TF-1D-GK": "JRP Cibubur",
"8A-V3-PB-85": "IFG LT.18",
"F6-2V-DT-8A": "CPI Balaraja"
};
const agents: Agent[] = (dlResult.data || []).map((item: any, idx: number) => {
const uuid = item.agent?.uuid || `agent-${idx}`;
const dl = item.download ?? 0;
const ul = ulMap[uuid] ?? 0;
const totalMB = ((dl + ul) / (1024 * 1024)).toFixed(2);
return {
id: item.agent?.id || idx,
uuid: uuid,
serial: uuid,
site_uuid: SITE_UUID || "",
organization_uuid: "",
provisioned: true,
activated: true,
label: AGENT_LABELS[uuid] || item.agent?.label || uuid,
created_at: { human: "N/A", date: "", unix_time: 0 },
updated_at: { human: "N/A", date: "", unix_time: 0 },
last_seen_at: {
human: `Active (Traffic: ${totalMB} MB)`,
date: new Date().toISOString(),
unix_time: Date.now() / 1000
}
};
});
return agents;
}
const response = await fetch(API_URL, {
method: "GET",
headers: getHeaders(),
@@ -72,6 +137,12 @@ export async function getAgents(): Promise<Agent[]> {
export async function getAgent(agentId: string): Promise<Agent | null> {
try {
const API_KEY = process.env.NETIFY_API_KEY;
if (API_KEY && API_KEY.startsWith("sk_db_")) {
const agents = await getAgents();
return agents.find(a => a.uuid === agentId || String(a.id) === agentId) || null;
}
const response = await fetch(`${API_URL}/${agentId}`, {
method: "GET",
headers: getHeaders(),
@@ -92,6 +163,11 @@ export async function getAgent(agentId: string): Promise<Agent | null> {
export async function createAgent(agentId: string, label: string) {
try {
const API_KEY = process.env.NETIFY_API_KEY;
if (API_KEY && API_KEY.startsWith("sk_db_")) {
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan membuat Agents." };
}
const url = agentId
? `${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`
: `${API_URL}?label=${encodeURIComponent(label)}`;
@@ -115,6 +191,11 @@ export async function createAgent(agentId: string, label: string) {
export async function updateAgent(agentId: string, label: string) {
try {
const API_KEY = process.env.NETIFY_API_KEY;
if (API_KEY && API_KEY.startsWith("sk_db_")) {
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan mengubah Agents." };
}
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`, {
method: "PATCH",
headers: getHeaders(),
@@ -134,6 +215,11 @@ export async function updateAgent(agentId: string, label: string) {
export async function deleteAgent(agentId: string) {
try {
const API_KEY = process.env.NETIFY_API_KEY;
if (API_KEY && API_KEY.startsWith("sk_db_")) {
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan menghapus Agents." };
}
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}`, {
method: "DELETE",
headers: getHeaders(),
@@ -150,3 +236,4 @@ export async function deleteAgent(agentId: string) {
return { success: false, message: error.message };
}
}
+152
View File
@@ -458,3 +458,155 @@ export function useLookupApplications(page: number = 1, limit: number = 25, sear
return { data, error, isLoading };
}
// ─── INTERACTIVE DETAIL TYPES ──────────────────────────────────────────────────
export interface AgentDeviceItem {
ip_address: string | null;
mac_address: string | null;
device_label: string | null;
device_type: string | null;
os_label: string | null;
manufacturer: string | null;
last_seen: string | null;
download: number;
upload: number;
encrypted_pct: number | null;
risk_level: string | null;
has_insecure: boolean;
}
export interface AgentFlowItem {
flow_id?: string | null;
src_ip: string | null;
dst_ip: string | null;
dst_port: number | null;
protocol: string | null;
app_label?: string | null;
domain?: string | null;
download: number;
upload: number;
last_seen: string | null;
}
export interface AgentAppItem {
app_id: number | null;
app_label: string;
category: string | null;
favicon: string | null;
download: number;
upload: number;
}
export interface AgentSummary {
total_devices: number;
active_flows: number;
bandwidth_down: number;
bandwidth_up: number;
}
export interface AgentDetails {
agent_uuid: string;
agent_label: string;
summary: AgentSummary | null;
devices: AgentDeviceItem[];
flows: AgentFlowItem[];
top_apps: AgentAppItem[];
}
export interface DeviceFlowItem {
dst_ip: string | null;
dst_port: number | null;
protocol: string | null;
app_label?: string | null;
domain?: string | null;
download: number;
upload: number;
last_seen: string | null;
}
export interface SecurityInfo {
encrypted_pct: number | null;
encrypted_bytes: number | null;
unencrypted_bytes: number | null;
risk_level: string | null;
insecure_protocols: { protocol: string; risk: string }[];
device_type: string | null;
os_label: string | null;
manufacturer: string | null;
device_label: string | null;
reputation: string | null;
rep_score: number | null;
blacklisted: boolean;
}
export interface DeviceDetails {
ip: string;
total_download: number;
total_upload: number;
flow_count: number;
top_apps: AgentAppItem[];
flows: DeviceFlowItem[];
security_info: SecurityInfo | null;
}
export interface AppAgentItem {
agent_uuid: string | null;
agent_label: string;
download: number;
upload: number;
flow_count?: number;
}
export interface AppIpItem {
ip_address: string | null;
download: number;
upload: number;
flow_count?: number;
reputation?: string | null;
blacklisted?: boolean;
}
export interface AppDetails {
app_label: string;
total_download: number;
total_upload: number;
data_source?: string;
has_domain_breakdown?: boolean;
agent_scorecard: AppAgentItem[];
top_ips: AppIpItem[];
}
export interface SecurityDevice {
ip_address: string;
mac_address: string | null;
device_label: string | null;
encrypted_pct: number;
unencrypted: number;
encrypted: number;
total: number;
risk_level: 'Rawan' | 'Sedang' | 'Aman';
has_insecure: boolean;
device_type: string | null;
os_label: string | null;
manufacturer: string | null;
}
// ─── DETAIL FETCH FUNCTIONS (one-shot, no polling) ───────────────────────────
export async function fetchAgentDetails(uuid: string): Promise<AgentDetails> {
return fetcher<AgentDetails>(`/agent-details?uuid=${encodeURIComponent(uuid)}`);
}
export async function fetchDeviceDetails(ip: string): Promise<DeviceDetails> {
return fetcher<DeviceDetails>(`/device-details?ip=${encodeURIComponent(ip)}`);
}
export async function fetchAppDetails(label: string): Promise<AppDetails> {
return fetcher<AppDetails>(`/app-details?label=${encodeURIComponent(label)}`);
}
export async function fetchSecurityDevices(): Promise<SecurityDevice[]> {
return fetcher<SecurityDevice[]>('/security-devices');
}