feat(prod): production deployment fixes - env loader, mongoose module, CSS static files, security hardening, PDF export, new UI components

This commit is contained in:
Rafif-Riqullah-Siregar committed 2026-07-16 13:14:34 +07:00
1 parent a403f752f3
commit 46f33eb088
69 files changed
+7367 -744

No files matched your search

@@ -64,7 +64,7 @@ export default function NetworkIntelligencePage() {
}
const appCols: Column<AppCategoryStat>[] = [
{ header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
{ header: "#", accessor: (row, i) => i + 1, className: "w-[8%] text-center" },
{
header: "Category",
accessor: (row) => (
@@ -72,11 +72,11 @@ export default function NetworkIntelligencePage() {
<span className="border-b border-dashed border-white/30 pb-0.5">{row.category_label || '-'}</span>
</div>
),
className: "w-[180px] min-w-[180px] max-w-[180px]"
className: "w-[32%] text-center"
},
{ header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" },
{ header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" },
{ header: "Total", accessor: (row) => fmtBytes(row.total), className: "w-[120px] min-w-[120px] max-w-[120px]" },
{ header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "w-[20%] text-center" },
{ header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "w-[20%] text-center" },
{ header: "Total", accessor: (row) => fmtBytes(row.total), className: "w-[20%] text-center" },
];
const isLoading = appCategories.isLoading || continents.isLoading;
+64 -2
View File
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { fetchSecurityDevices, SecurityDevice } from "@/lib/api";
import { useTlsVersions, useTlsCiphers, useTlsSecurity } from "@/lib/api-with-context";
import { useTlsVersions, useTlsCiphers, useTlsSecurity, useSslSans } from "@/lib/api-with-context";
import RiskSummaryCards from "@/components/security-audit/RiskSummaryCards";
import DeviceRiskTable from "@/components/security-audit/DeviceRiskTable";
import SecurityDistribution from "@/components/security-audit/SecurityDistribution";
@@ -12,12 +12,60 @@ import TlsTables from "@/components/security-audit/TlsTables";
import SslSanTable from "@/components/security-audit/SslSanTable";
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import { Loader2, ArrowDownToLine } from "lucide-react";
export default function SecurityAuditPage() {
const [mounted, setMounted] = useState(false);
const versions = useTlsVersions(10);
const ciphers = useTlsCiphers(20);
const security = useTlsSecurity(10);
const sslSans = useSslSans(100);
const [isExporting, setIsExporting] = useState(false);
const exportToPdf = async () => {
setIsExporting(true);
try {
const { pdf } = await import("@react-pdf/renderer");
const { default: SecurityAuditPdfDocument } = await import("@/components/security-audit/SecurityAuditPdfDocument");
const reportDateStr =
new Date().toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
const doc = (
<SecurityAuditPdfDocument
secDevices={filteredDevices}
versionsData={versions.data || []}
ciphersData={ciphers.data || []}
sslSansData={sslSans.data || []}
vulnerableCount={vulnerableCount}
moderateCount={moderateCount}
safeCount={safeCount}
reportDateStr={reportDateStr}
/>
);
const blob = await pdf(doc).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `Security_Encryption_Audit_Report.pdf`;
link.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error("Failed to export PDF:", err);
alert("Failed to generate PDF report. Please try again.");
} finally {
setIsExporting(false);
}
};
const [secDevices, setSecDevices] = useState<SecurityDevice[]>([]);
const [secDevicesLoading, setSecDevicesLoading] = useState(true);
@@ -61,7 +109,21 @@ export default function SecurityAuditPage() {
Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles.
</p>
</div>
<HelpTrigger pageId="security-audit" />
<div className="flex items-center gap-3">
<button
onClick={exportToPdf}
disabled={isExporting || secDevicesLoading || !versions.data || sslSans.isLoading}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<ArrowDownToLine className="w-3.5 h-3.5" />
)}
Export PDF
</button>
<HelpTrigger pageId="security-audit" />
</div>
</div>
<UniversalFilters
+3 -317
View File
@@ -1,322 +1,8 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable, Column } from "@/components/ui/DataTable";
import { Badge } from "@/components/ui/Badge";
import { useState, useEffect, useMemo, Suspense } from "react";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { ShieldCheck, Loader2 } from "lucide-react";
import { ThreatStat } from "@/lib/api";
import { useThreats } from "@/lib/api-with-context";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { ThreatFilters } from "@/components/threats/ThreatFilters";
import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer";
import { useSearchParams } from "next/navigation";
import { formatAppLabel } from "@/lib/utils";
import { HelpTrigger } from "@/components/help/HelpTrigger";
// ─── ThreatsContent ───────────────────────────────────────────────────────────
function ThreatsContent() {
const [mounted, setMounted] = useState(false);
const [selectedIp, setSelectedIp] = useState<string | null>(null);
const [selectedMac, setSelectedMac] = useState<string | null>(null);
const [selectedThreat, setSelectedThreat] = useState<ThreatStat | null>(null);
const threats = useThreats();
const searchParams = useSearchParams();
const highlightId = searchParams.get("highlight");
// Column dropdown filters state
const [fType, setFType] = useState("All");
const [fSev, setFSev] = useState("All");
const [fSrcIp, setFSrcIp] = useState("All");
const [fDstIp, setFDstIp] = useState("All");
const [fMac, setFMac] = useState("All");
const [fApp, setFApp] = useState("All");
const [fDomain, setFDomain] = useState("All");
// Date range filter state
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
useEffect(() => {
setMounted(true);
}, []);
const all = threats.data || [];
// Apply all filters
const filtered = useMemo(() => {
return all.filter((row) => {
if (fType !== "All" && (row.threat_type || "") !== fType) return false;
if (fSev !== "All" && (row.severity || "") !== fSev) return false;
if (fSrcIp !== "All" && (row.ip_address || "") !== fSrcIp) return false;
if (fDstIp !== "All" && (row.dst_ip || "") !== fDstIp) return false;
if (fMac !== "All" && (row.mac_address || "") !== fMac) return false;
if (fApp !== "All" && (row.app_label || "") !== fApp) return false;
if (fDomain !== "All" && (row.domain || "") !== fDomain) return false;
if (dateFrom || dateTo) {
const d = row.detected_at
? new Date(row.detected_at).toISOString().slice(0, 10)
: "";
if (dateFrom && d < dateFrom) return false;
if (dateTo && d > dateTo) return false;
}
return true;
});
}, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]);
// Automatically select a threat if highlightId is passed
useEffect(() => {
if (highlightId && all.length > 0) {
const match = all.find(r => r.id.toString() === highlightId);
if (match) {
setSelectedThreat(match);
}
}
}, [highlightId, all]);
if (!mounted) return null;
const getSeverityBadge = (sev?: string | null) => {
if (!sev) return <Badge variant="green">Unknown</Badge>;
const s = sev.toLowerCase();
if (s.includes("high") || s.includes("critical")) return <Badge variant="red">{sev}</Badge>;
if (s.includes("med")) return <Badge variant="orange">{sev}</Badge>;
if (s.includes("low")) return <Badge variant="yellow">{sev}</Badge>;
return <Badge variant="blue">{sev}</Badge>;
};
const columns: Column<ThreatStat>[] = [
{
header: "Timestamp",
className: "w-[155px] min-w-[155px] max-w-[155px] text-center",
accessor: (row) => <TimestampCell timestamp={row.detected_at} showActiveStatus={false} />
},
{ header: "#", className: "w-[40px] min-w-[40px] max-w-[40px] text-center", accessor: (_row, i) => i + 1 },
{
header: "Type",
className: "w-[170px] min-w-[170px] max-w-[170px]",
accessor: (row) => (
<div
title={row.description || row.threat_type || "Unknown"}
className="truncate w-full block cursor-help text-xs leading-tight"
>
{row.threat_type || "Unknown"}
</div>
),
},
{
header: "Severity",
className: "w-[90px] min-w-[90px] max-w-[90px] text-center",
accessor: (row) => getSeverityBadge(row.severity),
},
{
header: "Source IP",
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
accessor: (row) =>
row.ip_address ? (
<button
onClick={() => setSelectedIp(row.ip_address)}
className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center"
>
{row.ip_address}
</button>
) : (
<span className="text-muted-foreground text-xs" title="Source IP tidak tersedia dari data event keamanan (hanya MAC Address yang ter-capture)">–</span>
),
},
{
header: "Dest IP",
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
accessor: (row) =>
row.dst_ip ? (
<button
onClick={() => setSelectedIp(row.dst_ip)}
className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center"
>
{row.dst_ip}
</button>
) : (
<span className="text-muted-foreground text-xs" title="Dest IP tidak tersedia dari data event ini">–</span>
),
},
{
header: "MAC Addr",
className: "w-[140px] min-w-[140px] max-w-[140px] text-center",
accessor: (row) =>
row.mac_address ? (
<button
onClick={() => setSelectedMac(row.mac_address)}
className="text-xs bg-secondary/50 px-1.5 py-0.5 rounded text-muted-foreground hover:text-white hover:bg-secondary transition-colors font-mono focus:outline-none w-full text-center truncate"
title={row.mac_address}
>
{row.mac_address}
</button>
) : "–",
},
{
header: "App",
className: "w-[110px] min-w-[110px] max-w-[110px] text-center",
accessor: (row) => {
const formatted = formatAppLabel(row.app_label);
return formatted ? (
<div title={formatted} className="truncate w-full block text-center cursor-help text-xs">
{formatted}
</div>
) : (
<span className="text-muted-foreground text-xs" title="App tidak tersedia dari event ini">–</span>
);
},
},
{
header: "Domain",
className: "w-[160px] min-w-[160px] max-w-[160px] text-center",
accessor: (row) => row.domain ? (
<div title={row.domain} className="truncate w-full text-center text-xs cursor-help">
{row.domain}
</div>
) : (
<span className="text-muted-foreground text-xs" title="Domain tidak tersedia dari event ini">–</span>
),
},
{
header: "Action",
className: "w-[130px] min-w-[130px] max-w-[130px] text-center",
accessor: (row) => (
<button
onClick={() => setSelectedThreat(row)}
className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer whitespace-nowrap focus:outline-none"
>
View Mitigation
</button>
),
},
];
const activeFilterCount =
[fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain].filter((v) => v !== "All").length +
(dateFrom || dateTo ? 1 : 0);
const resetFilters = () => {
setFType("All");
setFSev("All");
setFSrcIp("All");
setFDstIp("All");
setFMac("All");
setFApp("All");
setFDomain("All");
setDateFrom("");
setDateTo("");
};
return (
<div className="space-y-8 animate-fade-in pb-10">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
Threat Intelligence
{threats.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
</h2>
<p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p>
</div>
<div className="flex items-center gap-3">
<HelpTrigger pageId="intelligence" />
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
<ShieldCheck className="h-4 w-4" />
<span>Active Monitoring</span>
</div>
</div>
</div>
{/* Filters above the table */}
<ThreatFilters
all={all}
fType={fType}
setFType={setFType}
fSev={fSev}
setFSev={setFSev}
fSrcIp={fSrcIp}
setFSrcIp={setFSrcIp}
fDstIp={fDstIp}
setFDstIp={setFDstIp}
fMac={fMac}
setFMac={setFMac}
fApp={fApp}
setFApp={setFApp}
fDomain={fDomain}
setFDomain={setFDomain}
dateFrom={dateFrom}
setDateFrom={setDateFrom}
dateTo={dateTo}
setDateTo={setDateTo}
activeFilterCount={activeFilterCount}
onReset={resetFilters}
/>
{/* Threats Table - Full Width */}
<Card
className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up relative z-10 overflow-visible"
style={{ animationDelay: "100ms" }}
>
<CardHeader className="border-b border-border/50 pb-4 mb-4">
<CardTitle className="text-lg text-white">
Detected Threats
<span className="ml-2 text-muted-foreground font-normal">
({filtered.length}
{filtered.length !== all.length ? ` of ${all.length}` : ""})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<DataTable
data={filtered}
columns={columns}
searchPlaceholder="Search threats by IP, domain, type..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.ip_address || "").toLowerCase().includes(q) ||
(row.domain || "").toLowerCase().includes(q) ||
(row.threat_type || "").toLowerCase().includes(q) ||
(row.app_label || "").toLowerCase().includes(q)
);
}}
getRowClassName={(row) =>
highlightId && row.id.toString() === highlightId
? "bg-red-500/15 border-l-2 border-red-500 animate-pulse font-semibold"
: ""
}
csvExport={{
filename: `threats-${new Date().toISOString().slice(0, 10)}.csv`,
headers: ["Timestamp", "Type", "Severity", "Source IP", "Dest IP", "MAC Address", "App", "Domain"],
rowSerializer: (row) => [
row.detected_at ? new Date(row.detected_at).toISOString() : "",
row.threat_type || "",
row.severity || "",
row.ip_address || "",
row.dst_ip || "",
row.mac_address || "",
row.app_label || "",
row.domain || "",
],
}}
/>
</CardContent>
</Card>
{selectedIp && <DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />}
{selectedMac && <DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />}
{/* Mitigation recommendations drawer panel */}
<ThreatRecommendationsDrawer
threat={selectedThreat}
onClose={() => setSelectedThreat(null)}
/>
</div>
);
}
import React, { Suspense } from "react";
import { Loader2 } from "lucide-react";
import ThreatsContent from "@/components/threats/ThreatsContent";
export default function ThreatsPage() {
return (
+1 -1
View File
@@ -2,7 +2,7 @@ import HelpPageContent from "@/components/help/HelpPageContent";
export const metadata = {
title: "User Guide — Deep Package Inspection",
description: "Panduan lengkap cara membaca data di setiap halaman dashboard DPI.",
description: "Comprehensive guide on reading data across every DPI dashboard page.",
};
export default function HelpRoute() {
+8 -8
View File
@@ -40,8 +40,8 @@ function SectionBlock({ heading, content, columns, items }: PageGuide["sections"
{columns && columns.length > 0 && (
<div className="rounded-lg overflow-hidden border border-white/5">
<div className="grid grid-cols-[140px_1fr] bg-white/[0.04] px-3 py-2 border-b border-white/5">
<span className="text-[10px] font-bold uppercase tracking-widest text-blue-400">Kolom</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Cara Membaca</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-blue-400">Column</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">How to Read</span>
</div>
{columns.map((col, i) => (
<div
@@ -136,12 +136,12 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM
</span>
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-bold text-white">Panduan: {guide.title}</h3>
<h3 className="text-base font-bold text-white">Guide: {guide.title}</h3>
<code className="text-[10px] px-1.5 py-0.5 rounded font-mono bg-white/5 text-slate-400 border border-white/5">
{guide.url}
</code>
</div>
<p className="text-xs text-slate-400 mt-0.5">Penjelasan tata letak data dan kolom metrik</p>
<p className="text-xs text-slate-400 mt-0.5">Metric columns and data layout guide</p>
</div>
</div>
<button
@@ -155,7 +155,7 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM
{/* Scrollable Body */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
<div className="rounded-xl border border-blue-500/10 bg-blue-500/5 px-4 py-3 text-xs text-slate-300 leading-relaxed">
<span className="font-semibold text-blue-400">Tentang Halaman Ini:</span> {guide.summary}
<span className="font-semibold text-blue-400">About This Page:</span> {guide.summary}
</div>
{guide.sections.map((section, idx) => (
@@ -166,20 +166,20 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM
{/* Footer */}
<div className="border-t border-white/6 bg-white/[0.02] px-6 py-4 flex items-center justify-between">
<span className="text-[10px] text-slate-500">
Pencarian detail lengkap tersedia di User Guide utama.
Full detailed lookup is available in the main User Guide.
</span>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="rounded-xl border border-white/8 bg-white/5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white transition-all"
>
Tutup Panduan
Close Guide
</button>
<Link
href="/help"
className="flex items-center gap-1 rounded-xl bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-md shadow-blue-600/10 hover:bg-blue-500 transition-all"
>
Buka Panduan Lengkap
Open Full Guide
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</div>
+20 -13
View File
@@ -49,8 +49,8 @@ function SectionBlock({ heading, content, columns, items }: PageGuide["sections"
{columns && (
<div className="rounded-xl overflow-hidden border border-white/8">
<div className="grid grid-cols-[180px_1fr] bg-gradient-to-r from-blue-600/20 to-indigo-600/10 px-4 py-2.5 border-b border-white/8">
<span className="text-[11px] font-bold uppercase tracking-widest text-blue-400">Kolom / Field</span>
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Cara Membaca</span>
<span className="text-[11px] font-bold uppercase tracking-widest text-blue-400">Column / Field</span>
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">How to Read</span>
</div>
{columns.map((col, i) => (
<div key={i} className={`grid grid-cols-[180px_1fr] px-4 py-3 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}>
@@ -95,12 +95,12 @@ export default function HelpPage() {
</div>
<div>
<h1 className="text-base font-bold text-white">User Guide</h1>
<p className="text-xs text-slate-400 mt-0.5">Panduan membaca data dashboard Deep Package Inspection</p>
<p className="text-xs text-slate-400 mt-0.5">Guide to reading Deep Packet Inspection dashboard data</p>
</div>
</div>
<Link href="/" className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-xs font-medium text-slate-300 hover:bg-white/10 hover:text-white transition-all hover:border-white/20 group">
<ArrowLeft className="h-3.5 w-3.5 group-hover:-translate-x-0.5 transition-transform" />
Kembali ke Dashboard
Back to Dashboard
</Link>
</div>
</div>
@@ -109,7 +109,7 @@ export default function HelpPage() {
{/* ── Sidebar ── */}
<aside className="w-52 shrink-0">
<div className="sticky top-6 space-y-1">
<p className="px-3 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-500">Halaman Dashboard</p>
<p className="px-3 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-500">Dashboard Pages</p>
{PAGE_GUIDES.map(page => {
const isActive = activeId === page.id;
return (
@@ -140,7 +140,7 @@ export default function HelpPage() {
>
{activeId === "conventions" && <span className="absolute left-0 top-1/4 h-1/2 w-0.5 rounded-full bg-blue-500" />}
<Info className={`h-4 w-4 shrink-0 ${activeId === "conventions" ? "text-blue-400" : "text-slate-500"}`} />
<span>Konvensi Visual</span>
<span>Visual Conventions</span>
</button>
</div>
</div>
@@ -154,15 +154,15 @@ export default function HelpPage() {
<div className="flex items-center gap-3 mb-1">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/30 to-indigo-500/20 border border-purple-500/20 text-lg">🎨</span>
<div>
<h2 className="text-lg font-bold text-white">Konvensi Visual</h2>
<p className="text-xs text-slate-400">Panduan membaca indikator dan simbol di seluruh halaman dashboard</p>
<h2 className="text-lg font-bold text-white">Visual Conventions</h2>
<p className="text-xs text-slate-400">Guide to reading indicators and symbols across dashboard pages</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/8 overflow-hidden shadow-lg">
<div className="grid grid-cols-[200px_1fr] bg-gradient-to-r from-purple-600/20 to-indigo-600/10 px-5 py-3 border-b border-white/8">
<span className="text-[11px] font-bold uppercase tracking-widest text-purple-400">Simbol / Elemen</span>
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Artinya</span>
<span className="text-[11px] font-bold uppercase tracking-widest text-purple-400">Symbol / Element</span>
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Meaning</span>
</div>
{UI_CONVENTIONS.map((c, i) => (
<div key={i} className={`grid grid-cols-[200px_1fr] px-5 py-3.5 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}>
@@ -174,10 +174,17 @@ export default function HelpPage() {
<div className="rounded-2xl border border-blue-500/20 bg-gradient-to-br from-blue-600/10 to-indigo-600/5 p-5">
<div className="flex items-center gap-2 mb-3">
<Lightbulb className="h-4 w-4 text-yellow-400" />
<span className="text-sm font-semibold text-yellow-300">Tips Membaca Data Secara Efektif</span>
<span className="text-sm font-semibold text-yellow-300">Tips for Reading Data Effectively</span>
</div>
<ul className="space-y-2 text-xs text-slate-300">
{["Selalu cek Time Filter sebelum menganalisis — pastikan rentang waktu sesuai kebutuhan.","Gunakan filter tabel untuk mempersempit data secara spesifik.","Hover ke teks yang terpotong (...) untuk melihat nilai lengkap.","Bandingkan Download vs Upload — upload jauh lebih besar bisa mengindikasikan data exfiltration.","Gunakan Geo Traffic untuk mendeteksi koneksi ke negara yang tidak seharusnya.","Cek Threat Intelligence secara rutin untuk mengetahui ancaman aktif."].map((tip, i) => (
{[
"Always check the Time Filter before analyzing — ensure the time range fits your needs.",
"Use table filters to narrow down data specifically.",
"Hover over truncated text (...) to view the full value.",
"Compare Download vs Upload — a significantly larger upload could indicate data exfiltration.",
"Use Geo Traffic to detect connections to countries that should not be accessed.",
"Regularly check Threat Intelligence to identify active threats."
].map((tip, i) => (
<li key={i} className="flex gap-2"><span className="text-blue-400 shrink-0 mt-0.5">•</span><span className="leading-relaxed">{tip}</span></li>
))}
</ul>
@@ -207,7 +214,7 @@ export default function HelpPage() {
</div>
) : (
<div className="flex h-40 items-center justify-center text-slate-500 text-sm">
Pilih halaman dari sidebar untuk melihat panduannya.
Select a page from the sidebar to view its guide.
</div>
)}
</main>
+4 -4
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useState } from "react";
import { HelpCircle } from "lucide-react";
import { BookOpen } from "lucide-react";
import { ContextualHelpModal } from "./ContextualHelpModal";
interface HelpTriggerProps {
@@ -17,10 +17,10 @@ export function HelpTrigger({ pageId, className = "" }: HelpTriggerProps) {
<button
onClick={() => setIsOpen(true)}
className={`group flex items-center gap-1.5 rounded-lg border border-white/8 bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white hover:border-white/20 transition-all ${className}`}
title="Pelajari cara membaca data halaman ini"
title="Learn how to read data on this page"
>
<HelpCircle className="h-3.5 w-3.5 text-blue-500 group-hover:text-blue-400 transition-colors" />
<span>Pelajari Halaman Ini</span>
<BookOpen className="h-3.5 w-3.5 text-blue-500 group-hover:text-blue-400 transition-colors" />
<span>Learn This Page</span>
</button>
<ContextualHelpModal
@@ -0,0 +1,195 @@
import React from "react";
import { Document, Page, Text, View } from "@react-pdf/renderer";
import { sharedStyles } from "../ui/sharedPdfStyles";
interface SecurityAuditPdfDocumentProps {
secDevices: any[];
versionsData: any[];
ciphersData: any[];
sslSansData: any[];
vulnerableCount: number;
moderateCount: number;
safeCount: number;
reportDateStr: string;
}
interface TableColumn {
header: string;
width: string;
align?: "center" | "right" | "left";
cell: (row: any) => string | number;
}
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const k = 1000;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatIndonesiaTime(dateStr: string | Date): string {
if (!dateStr) return "Never";
try {
const d = new Date(dateStr);
return d.toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
} catch (e) {
return String(dateStr);
}
}
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
return (
<View style={{ marginBottom: 15 }}>
<Text style={sharedStyles.sectionTitle}>{title}</Text>
<View style={sharedStyles.table}>
<View style={sharedStyles.tableHeader}>
{columns.map((col, idx) => (
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
{col.header}
</Text>
</View>
))}
</View>
{data.length === 0 ? (
<View style={sharedStyles.tableRow}>
<Text style={sharedStyles.emptyText}>No Data</Text>
</View>
) : (
data.map((row, rowIdx) => (
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
{columns.map((col, colIdx) => (
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
{col.cell(row)}
</Text>
</View>
))}
</View>
))
)}
</View>
</View>
);
}
export default function SecurityAuditPdfDocument({
secDevices,
versionsData,
ciphersData,
sslSansData,
vulnerableCount,
moderateCount,
safeCount,
reportDateStr,
}: SecurityAuditPdfDocumentProps) {
// Compliance check: show all data up to 50 threshold
const vulnerableDevices = secDevices.filter((d) => d.risk_level === "Vulnerable").slice(0, 50);
const otherDevices = secDevices.filter((d) => d.risk_level !== "Vulnerable").slice(0, 50);
const sslSans = (sslSansData || []).slice(0, 50);
const versions = (versionsData || []).slice(0, 50);
const ciphers = (ciphersData || []).slice(0, 50);
return (
<Document>
<Page size="A4" style={sharedStyles.page}>
{/* Header */}
<View style={sharedStyles.header}>
<View>
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
</View>
<View style={sharedStyles.titleContainer}>
<Text style={sharedStyles.title}>Security & Encryption Audit</Text>
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
</View>
</View>
{/* Global Security Summary */}
<Text style={sharedStyles.sectionTitle}>Compliance & Risk Summary</Text>
<View style={sharedStyles.bandwidthGrid}>
<View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fef2f2", borderColor: "#fecaca" }}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#b91c1c" }}>VULNERABLE DEVICES</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#991b1b" }}>{vulnerableCount}</Text>
</View>
<View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fffbeb", borderColor: "#fef3c7" }}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#d97706" }}>MODERATE RISK DEVICES</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#b45309" }}>{moderateCount}</Text>
</View>
<View style={sharedStyles.bandwidthCardGreen}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>SECURE DEVICES</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{safeCount}</Text>
</View>
</View>
{/* TLS Versions */}
<RenderPdfTable
title="TLS Version Distribution"
data={versions}
columns={[
{ header: "Protocol Version", width: "40%", cell: (r) => r.version || "Unknown TLS" },
{ header: "Flow Count", width: "30%", align: "right", cell: (r) => r.count ?? 0 },
{ header: "Data Transmitted", width: "30%", align: "right", cell: (r) => formatBytes(r.bytes) },
]}
/>
{/* Top Active Cipher Suites */}
<RenderPdfTable
title="Top Active Cipher Suites"
data={ciphers}
columns={[
{ header: "Cipher Suite", width: "50%", cell: (r) => r.cipher || "Unknown" },
{ header: "Version", width: "20%", cell: (r) => r.version || "—" },
{ header: "Usage Count", width: "30%", align: "right", cell: (r) => r.count ?? 0 },
]}
/>
{/* SSL/TLS Subject Alternative Names */}
<RenderPdfTable
title="SSL/TLS Subject Alternative Names (SAN)"
data={sslSans}
columns={[
{ header: "Subject Alternative Name (SAN)", width: "40%", cell: (r) => r.alt_name || "—" },
{ header: "Flows", width: "15%", align: "center", cell: (r) => r.flow_count ?? 0 },
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
{ header: "Last Seen", width: "15%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" },
]}
/>
{/* Vulnerable Devices Table */}
<RenderPdfTable
title="Vulnerable Devices (Action Required)"
data={vulnerableDevices}
columns={[
{ header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" },
{ header: "Device Label / OS", width: "30%", cell: (r) => r.device_label || r.os_label || "Unknown" },
{ header: "Encrypted %", width: "20%", align: "right", cell: (r) => `${r.encrypted_pct ?? 0}%` },
{ header: "Risk Level", width: "20%", align: "center", cell: (r) => r.risk_level || "—" },
]}
/>
{/* Other Devices Table */}
<RenderPdfTable
title="Other Monitored Devices"
data={otherDevices}
columns={[
{ header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" },
{ header: "Device Label / OS", width: "30%", cell: (r) => r.device_label || r.os_label || "Unknown" },
{ header: "Encrypted %", width: "20%", align: "right", cell: (r) => `${r.encrypted_pct ?? 0}%` },
{ header: "Risk Level", width: "20%", align: "center", cell: (r) => r.risk_level || "—" },
]}
/>
</Page>
</Document>
);
}
+253
View File
@@ -0,0 +1,253 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable } from "@/components/ui/DataTable";
import { ShieldCheck, Loader2, ArrowDownToLine } from "lucide-react";
import { ThreatStat } from "@/lib/api";
import { useThreats } from "@/lib/api-with-context";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { ThreatFilters } from "@/components/threats/ThreatFilters";
import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer";
import { useSearchParams } from "next/navigation";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import { getThreatColumns } from "./threatColumns";
export default function ThreatsContent() {
const [mounted, setMounted] = useState(false);
const [selectedIp, setSelectedIp] = useState<string | null>(null);
const [selectedMac, setSelectedMac] = useState<string | null>(null);
const [selectedThreat, setSelectedThreat] = useState<ThreatStat | null>(null);
const threats = useThreats();
const searchParams = useSearchParams();
const highlightId = searchParams.get("highlight");
// Column dropdown filters state
const [fType, setFType] = useState("All");
const [fSev, setFSev] = useState("All");
const [fSrcIp, setFSrcIp] = useState("All");
const [fDstIp, setFDstIp] = useState("All");
const [fMac, setFMac] = useState("All");
const [fApp, setFApp] = useState("All");
const [fDomain, setFDomain] = useState("All");
// Date range filter state
const [dateFrom, setDateFrom] = useState("");
const [dateTo, setDateTo] = useState("");
useEffect(() => {
setMounted(true);
}, []);
const all = threats.data || [];
// Apply all filters
const filtered = useMemo(() => {
return all.filter((row) => {
if (fType !== "All" && (row.threat_type || "") !== fType) return false;
if (fSev !== "All" && (row.severity || "") !== fSev) return false;
if (fSrcIp !== "All" && (row.ip_address || "") !== fSrcIp) return false;
if (fDstIp !== "All" && (row.dst_ip || "") !== fDstIp) return false;
if (fMac !== "All" && (row.mac_address || "") !== fMac) return false;
if (fApp !== "All" && (row.app_label || "") !== fApp) return false;
if (fDomain !== "All" && (row.domain || "") !== fDomain) return false;
if (dateFrom || dateTo) {
const d = row.detected_at
? new Date(row.detected_at).toISOString().slice(0, 10)
: "";
if (dateFrom && d < dateFrom) return false;
if (dateTo && d > dateTo) return false;
}
return true;
});
}, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]);
// Automatically select a threat if highlightId is passed
useEffect(() => {
if (highlightId && all.length > 0) {
const match = all.find(r => r.id.toString() === highlightId);
if (match) {
setSelectedThreat(match);
}
}
}, [highlightId, all]);
const [isExporting, setIsExporting] = useState(false);
const exportToPdf = async () => {
setIsExporting(true);
try {
const { pdf } = await import("@react-pdf/renderer");
const { default: ThreatsSummaryPdfDocument } = await import("./ThreatsSummaryPdfDocument");
const reportDateStr =
new Date().toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
const doc = (
<ThreatsSummaryPdfDocument
threatsData={filtered}
reportDateStr={reportDateStr}
/>
);
const blob = await pdf(doc).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `Threat_Intelligence_Summary_Report.pdf`;
link.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error("Failed to export PDF:", err);
alert("Failed to generate PDF report. Please try again.");
} finally {
setIsExporting(false);
}
};
const columns = useMemo(() => {
return getThreatColumns(setSelectedIp, setSelectedMac, setSelectedThreat, highlightId);
}, [highlightId]);
const activeFilterCount =
[fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain].filter((v) => v !== "All").length +
(dateFrom || dateTo ? 1 : 0);
const resetFilters = () => {
setFType("All");
setFSev("All");
setFSrcIp("All");
setFDstIp("All");
setFMac("All");
setFApp("All");
setFDomain("All");
setDateFrom("");
setDateTo("");
};
if (!mounted) return null;
return (
<div className="space-y-8 animate-fade-in pb-10">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
Threat Intelligence
{threats.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
</h2>
<p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={exportToPdf}
disabled={isExporting || threats.isLoading || filtered.length === 0}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<ArrowDownToLine className="w-3.5 h-3.5" />
)}
Export PDF
</button>
<HelpTrigger pageId="intelligence" />
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
<ShieldCheck className="h-4 w-4" />
<span>Active Monitoring</span>
</div>
</div>
</div>
<ThreatFilters
all={all}
fType={fType}
setFType={setFType}
fSev={fSev}
setFSev={setFSev}
fSrcIp={fSrcIp}
setFSrcIp={setFSrcIp}
fDstIp={fDstIp}
setFDstIp={setFDstIp}
fMac={fMac}
setFMac={setFMac}
fApp={fApp}
setFApp={setFApp}
fDomain={fDomain}
setFDomain={setFDomain}
dateFrom={dateFrom}
setDateFrom={setDateFrom}
dateTo={dateTo}
setDateTo={setDateTo}
activeFilterCount={activeFilterCount}
onReset={resetFilters}
/>
<Card
className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up relative z-10 overflow-visible"
style={{ animationDelay: "100ms" }}
>
<CardHeader className="border-b border-border/50 pb-4 mb-4">
<CardTitle className="text-lg text-white">
Detected Threats
<span className="ml-2 text-muted-foreground font-normal">
({filtered.length}
{filtered.length !== all.length ? ` of ${all.length}` : ""})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<DataTable
data={filtered}
columns={columns}
searchPlaceholder="Search threats by IP, domain, type..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.ip_address || "").toLowerCase().includes(q) ||
(row.domain || "").toLowerCase().includes(q) ||
(row.threat_type || "").toLowerCase().includes(q) ||
(row.app_label || "").toLowerCase().includes(q)
);
}}
getRowClassName={(row) =>
highlightId && row.id.toString() === highlightId
? "bg-red-500/15 border-l-2 border-red-500 animate-pulse font-semibold"
: ""
}
csvExport={{
filename: `threats-${new Date().toISOString().slice(0, 10)}.csv`,
headers: ["Timestamp", "Type", "Severity", "Source IP", "Dest IP", "MAC Address", "App", "Domain"],
rowSerializer: (row) => [
row.detected_at ? new Date(row.detected_at).toISOString() : "",
row.threat_type || "",
row.severity || "",
row.ip_address || "",
row.dst_ip || "",
row.mac_address || "",
row.app_label || "",
row.domain || "",
],
}}
/>
</CardContent>
</Card>
{selectedIp && <DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />}
{selectedMac && <DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />}
<ThreatRecommendationsDrawer
threat={selectedThreat}
onClose={() => setSelectedThreat(null)}
/>
</div>
);
}
@@ -0,0 +1,185 @@
import React from "react";
import { Document, Page, Text, View } from "@react-pdf/renderer";
import { sharedStyles } from "../ui/sharedPdfStyles";
interface ThreatsSummaryPdfDocumentProps {
threatsData: any[];
reportDateStr: string;
}
interface TableColumn {
header: string;
width: string;
align?: "center" | "right" | "left";
cell: (row: any) => string | number;
}
function formatIndonesiaTime(dateStr: string | Date): string {
if (!dateStr) return "Never";
try {
const d = new Date(dateStr);
return d.toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
} catch (e) {
return String(dateStr);
}
}
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
return (
<View style={{ marginBottom: 15 }}>
<Text style={sharedStyles.sectionTitle}>{title}</Text>
<View style={sharedStyles.table}>
<View style={sharedStyles.tableHeader}>
{columns.map((col, idx) => (
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
{col.header}
</Text>
</View>
))}
</View>
{data.length === 0 ? (
<View style={sharedStyles.tableRow}>
<Text style={sharedStyles.emptyText}>No Data</Text>
</View>
) : (
data.map((row, rowIdx) => (
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
{columns.map((col, colIdx) => (
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
{col.cell(row)}
</Text>
</View>
))}
</View>
))
)}
</View>
</View>
);
}
export default function ThreatsSummaryPdfDocument({
threatsData,
reportDateStr,
}: ThreatsSummaryPdfDocumentProps) {
const total = threatsData.length;
const criticalHigh = threatsData.filter(
(t) => {
const s = (t.severity || "").toLowerCase();
return s.includes("high") || s.includes("critical");
}
).length;
const medium = threatsData.filter(
(t) => (t.severity || "").toLowerCase().includes("med")
).length;
const lowInfo = total - criticalHigh - medium;
// Group by threat type
const typeCounts: { [key: string]: number } = {};
threatsData.forEach((t) => {
const key = t.threat_type || "Unknown Alert";
typeCounts[key] = (typeCounts[key] || 0) + 1;
});
const topTypes = Object.entries(typeCounts)
.map(([type, count]) => ({ type, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
// Group by source IP
const ipCounts: { [key: string]: number } = {};
threatsData.forEach((t) => {
const key = t.ip_address || "Unknown IP";
ipCounts[key] = (ipCounts[key] || 0) + 1;
});
const topIps = Object.entries(ipCounts)
.map(([ip, count]) => ({ ip, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const incidents = threatsData.slice(0, 50);
return (
<Document>
<Page size="A4" style={sharedStyles.page}>
{/* Header */}
<View style={sharedStyles.header}>
<View>
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
</View>
<View style={sharedStyles.titleContainer}>
<Text style={sharedStyles.title}>Threat Intelligence Summary</Text>
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
</View>
</View>
{/* Threat Counters */}
<Text style={sharedStyles.sectionTitle}>Threat Metrics Summary</Text>
<View style={sharedStyles.bandwidthGrid}>
<View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fef2f2", borderColor: "#fecaca" }}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#b91c1c" }}>CRITICAL / HIGH THREATS</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#991b1b" }}>{criticalHigh}</Text>
</View>
<View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fffbeb", borderColor: "#fef3c7" }}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#d97706" }}>MEDIUM THREATS</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#b45309" }}>{medium}</Text>
</View>
<View style={sharedStyles.bandwidthCardGreen}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>LOW / INFO THREATS</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{lowInfo}</Text>
</View>
</View>
{/* Threat Types & Targets */}
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 10 }}>
<View style={{ width: "48%" }}>
<RenderPdfTable
title="Top Threat Types"
data={topTypes}
columns={[
{ header: "Threat Type", width: "70%", cell: (r) => r.type },
{ header: "Incidents", width: "30%", align: "right", cell: (r) => r.count },
]}
/>
</View>
<View style={{ width: "48%" }}>
<RenderPdfTable
title="Top Targeted Host IPs"
data={topIps}
columns={[
{ header: "Host IP Address", width: "70%", cell: (r) => r.ip },
{ header: "Incidents", width: "30%", align: "right", cell: (r) => r.count },
]}
/>
</View>
</View>
{/* Detailed Incidents List */}
<RenderPdfTable
title="Detailed Threat Log (Latest 30)"
data={incidents}
columns={[
{ header: "Time", width: "25%", cell: (r) => r.detected_at ? formatIndonesiaTime(r.detected_at) : "N/A" },
{ header: "Threat Type", width: "25%", cell: (r) => r.threat_type || "Security Alert" },
{ header: "Severity", width: "15%", align: "center", cell: (r) => (r.severity || "Unknown").toUpperCase() },
{ header: "Host IP", width: "15%", cell: (r) => r.ip_address || "—" },
{ header: "Target Destination", width: "20%", cell: (r) => r.dst_ip || r.domain || "—" },
]}
/>
</Page>
</Document>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { Column } from "@/components/ui/DataTable";
import { Badge } from "@/components/ui/Badge";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { ThreatStat } from "@/lib/api";
import { formatAppLabel } from "@/lib/utils";
export const getSeverityBadge = (sev?: string | null) => {
if (!sev) return <Badge variant="green">Unknown</Badge>;
const s = sev.toLowerCase();
if (s.includes("high") || s.includes("critical")) return <Badge variant="red">{sev}</Badge>;
if (s.includes("med")) return <Badge variant="orange">{sev}</Badge>;
if (s.includes("low")) return <Badge variant="yellow">{sev}</Badge>;
return <Badge variant="blue">{sev}</Badge>;
};
export function getThreatColumns(
setSelectedIp: (ip: string) => void,
setSelectedMac: (mac: string) => void,
setSelectedThreat: (threat: ThreatStat) => void,
highlightId: string | null
): Column<ThreatStat>[] {
return [
{
header: "Timestamp",
className: "w-[155px] min-w-[155px] max-w-[155px] text-center",
accessor: (row) => <TimestampCell timestamp={row.detected_at} showActiveStatus={false} />,
},
{ header: "#", className: "w-[40px] min-w-[40px] max-w-[40px] text-center", accessor: (_row, i) => i + 1 },
{
header: "Type",
className: "w-[170px] min-w-[170px] max-w-[170px]",
accessor: (row) => (
<div
title={row.description || row.threat_type || "Unknown"}
className="truncate w-full block cursor-help text-xs leading-tight"
>
{row.threat_type || "Unknown"}
</div>
),
},
{
header: "Severity",
className: "w-[90px] min-w-[90px] max-w-[90px] text-center",
accessor: (row) => getSeverityBadge(row.severity),
},
{
header: "Source IP",
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
accessor: (row) =>
row.ip_address ? (
<button
onClick={() => setSelectedIp(row.ip_address || "")}
className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center"
>
{row.ip_address}
</button>
) : (
<span className="text-muted-foreground text-xs" title="Source IP is not available from security event data (only MAC Address was captured)">–</span>
),
},
{
header: "Dest IP",
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
accessor: (row) =>
row.dst_ip ? (
<button
onClick={() => setSelectedIp(row.dst_ip || "")}
className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center"
>
{row.dst_ip}
</button>
) : (
<span className="text-muted-foreground text-xs" title="Dest IP is not available from this event data">–</span>
),
},
{
header: "MAC Addr",
className: "w-[140px] min-w-[140px] max-w-[140px] text-center",
accessor: (row) =>
row.mac_address ? (
<button
onClick={() => setSelectedMac(row.mac_address || "")}
className="text-xs bg-secondary/50 px-1.5 py-0.5 rounded text-muted-foreground hover:text-white hover:bg-secondary transition-colors font-mono focus:outline-none w-full text-center truncate"
title={row.mac_address}
>
{row.mac_address}
</button>
) : "–",
},
{
header: "App",
className: "w-[110px] min-w-[110px] max-w-[110px] text-center",
accessor: (row) => {
const formatted = formatAppLabel(row.app_label);
return formatted ? (
<div title={formatted} className="truncate w-full block text-center cursor-help text-xs">
{formatted}
</div>
) : (
<span className="text-muted-foreground text-xs" title="App is not available from this event">–</span>
);
},
},
{
header: "Domain",
className: "w-[160px] min-w-[160px] max-w-[160px] text-center",
accessor: (row) => row.domain ? (
<div title={row.domain} className="truncate w-full text-center text-xs cursor-help">
{row.domain}
</div>
) : (
<span className="text-muted-foreground text-xs" title="Domain is not available from this event">–</span>
),
},
{
header: "Action",
className: "w-[130px] min-w-[130px] max-w-[130px] text-center",
accessor: (row) => (
<button
onClick={() => setSelectedThreat(row)}
className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer whitespace-nowrap focus:outline-none"
>
View Mitigation
</button>
),
},
];
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import React from "react";
import { ChevronRight } from "lucide-react";
import { fmtBytes } from "@/lib/utils";
interface AgentAppItem {
app_id: number | null;
app_label: string;
category: string | null;
download: number;
upload: number;
}
interface AgentAppsTabProps {
topApps: AgentAppItem[];
onSelectApp: (label: string) => void;
}
export default function AgentAppsTab({ topApps, onSelectApp }: AgentAppsTabProps) {
if (topApps.length === 0) {
return <p className="text-muted-foreground text-sm text-center py-10">No applications accessed by this agent.</p>;
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{topApps.map((app) => {
const appTotal = app.download + app.upload;
return (
<button
key={app.app_id}
onClick={() => onSelectApp(app.app_label)}
className="flex items-center gap-3 p-3 bg-white/[0.03] hover:bg-white/[0.07] border border-border/50 rounded-xl transition-all group w-full text-left"
>
<div className="flex-1 min-w-0">
<p className="font-semibold text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors">
{app.app_label}
</p>
<p className="text-xs text-muted-foreground mt-0.5">{app.category || "Web"}</p>
</div>
<div className="text-right flex-shrink-0">
<p className="text-xs text-card-foreground font-semibold">{fmtBytes(appTotal)}</p>
<span className="text-[10px] text-muted-foreground flex gap-2">
<span className="text-cyan-400">DL: {fmtBytes(app.download)}</span>
<span className="text-green-400">UL: {fmtBytes(app.upload)}</span>
</span>
</div>
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 transition-colors" />
</button>
);
})}
</div>
);
}
+66 -33
View File
@@ -7,7 +7,7 @@ import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { AppDetailModal } from "@/components/ui/AppDetailModal";
import {
Loader2, X, Server, Monitor, Activity, Globe, Shield,
Bell, Wifi, ChevronRight, LineChart
Bell, Wifi, LineChart, ArrowDownToLine
} from "lucide-react";
import AgentDevicesTab from "./AgentDevicesTab";
@@ -16,6 +16,7 @@ import AgentSecurityTab from "./AgentSecurityTab";
import AgentEventsTab from "./AgentEventsTab";
import AgentMacTab from "./AgentMacTab";
import AgentTelemetryTab from "./AgentTelemetryTab";
import AgentAppsTab from "./AgentAppsTab";
interface Props {
agentUuid: string;
@@ -37,6 +38,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
const [selectedDevice, setSelectedDevice] = useState<{ ip: string; label: string | null; mac: string | null; agent_uuid?: string } | null>(null);
const [isClosing, setIsClosing] = useState(false);
const [selectedAppLabel, setSelectedAppLabel] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const handleClose = () => {
setIsClosing(true);
@@ -45,6 +47,49 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
}, 480);
};
const exportToPdf = async () => {
if (!data) return;
setIsExporting(true);
try {
const { pdf } = await import("@react-pdf/renderer");
const { default: AgentPdfDocument } = await import("./AgentPdfDocument");
const reportDateStr =
new Date().toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
const doc = (
<AgentPdfDocument
data={data}
agentLabel={agentLabel}
agentUuid={agentUuid}
reportDateStr={reportDateStr}
/>
);
const blob = await pdf(doc).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const safeLabel = agentLabel.replace(/[^a-zA-Z0-9]/g, "_");
link.download = `Agent_Report_${safeLabel}.pdf`;
link.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error("Failed to export PDF:", err);
alert("Failed to generate PDF report. Please try again.");
} finally {
setIsExporting(false);
}
};
useEffect(() => {
setIsLoading(true);
setError(null);
@@ -96,9 +141,23 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
<p className="text-xs text-muted-foreground font-mono">{agentUuid}</p>
</div>
</div>
<button onClick={handleClose} className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all">
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3">
<button
onClick={exportToPdf}
disabled={isExporting || isLoading || !data}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<ArrowDownToLine className="w-3.5 h-3.5" />
)}
Export PDF
</button>
<button onClick={handleClose} className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all">
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
@@ -162,35 +221,9 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
<AgentFlowsTab flows={data.flows} />
)}
{activeTab === "apps" && (
data.top_apps.length === 0 ? <EmptyState msg="No applications accessed by this agent." /> : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{data.top_apps.map((app) => {
const appTotal = app.download + app.upload;
return (
<button
key={app.app_id}
onClick={() => setSelectedAppLabel(app.app_label)}
className="flex items-center gap-3 p-3 bg-white/[0.03] hover:bg-white/[0.07] border border-border/50 rounded-xl transition-all group w-full text-left"
>
<div className="flex-1 min-w-0">
<p className="font-semibold text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors">{app.app_label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{app.category || "Web"}</p>
</div>
<div className="text-right flex-shrink-0">
<p className="text-xs text-card-foreground font-semibold">{fmtBytes(appTotal)}</p>
<span className="text-[10px] text-muted-foreground flex gap-2">
<span className="text-cyan-400">DL: {fmtBytes(app.download)}</span>
<span className="text-green-400">UL: {fmtBytes(app.upload)}</span>
</span>
</div>
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 transition-colors" />
</button>
);
})}
</div>
)
)}
{activeTab === "apps" && (
<AgentAppsTab topApps={data.top_apps} onSelectApp={setSelectedAppLabel} />
)}
{activeTab === "security" && (
<AgentSecurityTab data={data} />
+238
View File
@@ -0,0 +1,238 @@
import React from "react";
import { Document, Page, Text, View } from "@react-pdf/renderer";
import { sharedStyles } from "./sharedPdfStyles";
interface AgentPdfDocumentProps {
data: any;
agentLabel: string;
agentUuid: string;
reportDateStr: string;
}
interface TableColumn {
header: string;
width: string;
align?: "center" | "right" | "left";
cell: (row: any) => string | number;
}
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const k = 1000;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatIndonesiaTime(dateStr: string | Date): string {
if (!dateStr) return "Never";
try {
const d = new Date(dateStr);
return d.toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
} catch (e) {
return String(dateStr);
}
}
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
return (
<View style={{ marginBottom: 15 }}>
<Text style={sharedStyles.sectionTitle}>{title}</Text>
<View style={sharedStyles.table}>
<View style={sharedStyles.tableHeader}>
{columns.map((col, idx) => (
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
{col.header}
</Text>
</View>
))}
</View>
{data.length === 0 ? (
<View style={sharedStyles.tableRow}>
<Text style={sharedStyles.emptyText}>No Data</Text>
</View>
) : (
data.map((row, rowIdx) => (
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
{columns.map((col, colIdx) => (
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
{col.cell(row)}
</Text>
</View>
))}
</View>
))
)}
</View>
</View>
);
}
export default function AgentPdfDocument({
data,
agentLabel,
agentUuid,
reportDateStr,
}: AgentPdfDocumentProps) {
const summary = data.summary || {};
// Compliance check: show all data up to 50 threshold
const devices = (data.devices || []).slice(0, 50);
const flows = (data.flows || []).slice(0, 50);
const topApps = (data.top_apps || []).slice(0, 50);
const insecure = (data.security?.insecure_protocols || []).slice(0, 50);
const reputation = (data.security?.ip_reputation || []).slice(0, 50);
const events = (data.events || []).slice(0, 50);
const macBw = (data.mac_bandwidth || []).slice(0, 50);
return (
<Document>
<Page size="A4" style={sharedStyles.page}>
{/* Header */}
<View style={sharedStyles.header}>
<View>
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
</View>
<View style={sharedStyles.titleContainer}>
<Text style={sharedStyles.title}>Network Agent Report</Text>
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
</View>
</View>
{/* Agent Info */}
<Text style={sharedStyles.sectionTitle}>Agent Identification</Text>
<View style={sharedStyles.grid}>
<View style={{ ...sharedStyles.gridCol, width: "35%" }}>
<Text style={sharedStyles.gridLabel}>Agent Name</Text>
<Text style={sharedStyles.gridValue}>{agentLabel || "N/A"}</Text>
</View>
<View style={{ ...sharedStyles.gridCol, width: "65%" }}>
<Text style={sharedStyles.gridLabel}>Agent UUID</Text>
<Text style={sharedStyles.gridValue}>{agentUuid || "N/A"}</Text>
</View>
<View style={sharedStyles.gridCol}>
<Text style={sharedStyles.gridLabel}>Active Devices</Text>
<Text style={sharedStyles.gridValue}>{summary.total_devices ?? 0}</Text>
</View>
<View style={sharedStyles.gridCol}>
<Text style={sharedStyles.gridLabel}>Active Flows</Text>
<Text style={sharedStyles.gridValue}>{summary.active_flows ?? 0}</Text>
</View>
</View>
{/* Bandwidth Usage */}
<Text style={sharedStyles.sectionTitle}>Bandwidth Metrics</Text>
<View style={sharedStyles.bandwidthGrid}>
<View style={sharedStyles.bandwidthCardCyan}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#115e59" }}>{formatBytes(summary.bandwidth_down)}</Text>
</View>
<View style={sharedStyles.bandwidthCardGreen}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{formatBytes(summary.bandwidth_up)}</Text>
</View>
</View>
{/* Top Apps */}
<RenderPdfTable
title="Top Applications"
data={topApps}
columns={[
{ header: "Application", width: "40%", cell: (r) => r.app_label || "Unknown" },
{ header: "Category", width: "20%", cell: (r) => r.category || "—" },
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
]}
/>
{/* Monitored Devices */}
<RenderPdfTable
title="Monitored Devices"
data={devices}
columns={[
{ header: "IP Address", width: "22%", cell: (r) => r.ip_address || "N/A" },
{ header: "OS / Manufacturer", width: "28%", cell: (r) => r.os_label || r.manufacturer || "Unknown" },
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
{ header: "Last Seen", width: "20%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" },
]}
/>
{/* Recent Network Flows */}
<RenderPdfTable
title="Recent Network Flows"
data={flows}
columns={[
{ header: "Source IP", width: "20%", cell: (r) => r.src_ip || "—" },
{ header: "Destination IP", width: "20%", cell: (r) => r.dst_ip || "—" },
{ header: "Port", width: "10%", align: "center", cell: (r) => r.dst_port || "—" },
{ header: "App / Protocol", width: "20%", cell: (r) => r.app_label || r.protocol || "Unknown" },
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
]}
/>
{/* Insecure Protocols */}
<RenderPdfTable
title="Insecure Protocols"
data={insecure}
columns={[
{ header: "Host IP", width: "25%", cell: (r) => r.ip_address || "—" },
{ header: "Protocol", width: "15%", cell: (r) => r.protocol || "—" },
{ header: "Risk Level", width: "15%", cell: (r) => r.risk || "—" },
{ header: "Destination", width: "25%", cell: (r) => r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? ""}` : "—" },
{ header: "Bytes Traced", width: "20%", align: "right", cell: (r) => formatBytes((r.download ?? 0) + (r.upload ?? 0)) },
]}
/>
{/* IP Reputation Log */}
<RenderPdfTable
title="IP Reputation Log"
data={reputation}
columns={[
{ header: "Remote IP", width: "30%", cell: (r) => r.ip_address || "—" },
{ header: "Local IP", width: "30%", cell: (r) => r.local_ip || "—" },
{ header: "Reputation", width: "20%", cell: (r) => r.reputation || "—" },
{ header: "Risk Score", width: "20%", align: "right", cell: (r) => r.score ?? "—" },
]}
/>
{/* Security Events */}
<RenderPdfTable
title="Security Events Log"
data={events}
columns={[
{ header: "Event Type", width: "20%", cell: (r) => r.event_type || "—" },
{ header: "Severity", width: "15%", align: "center", cell: (r) => r.severity || "—" },
{ header: "IP Address", width: "20%", cell: (r) => r.ip_address || "—" },
{ header: "Description", width: "25%", cell: (r) => r.description || "—" },
{ header: "Event Time", width: "20%", cell: (r) => r.event_at ? formatIndonesiaTime(r.event_at) : "—" },
]}
/>
{/* MAC Bandwidth */}
<RenderPdfTable
title="MAC Bandwidth Distribution"
data={macBw}
columns={[
{ header: "MAC Address", width: "30%", cell: (r) => r.mac_address || "—" },
{ header: "Manufacturer", width: "30%", cell: (r) => r.manufacturer || "Unknown" },
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
]}
/>
</Page>
</Document>
);
}
+65 -21
View File
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
import { fetchAppDetails, AppDetails } from "@/lib/api";
import { useTimeFilter } from "@/contexts/TimeFilterContext";
import { fmtBytes } from "@/lib/utils";
import { Loader2, X, Globe, Server, Monitor } from "lucide-react";
import { Loader2, X, Globe, Server, Monitor, ArrowDownToLine } from "lucide-react";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import ActiveDurationDisplay from "./ActiveDurationDisplay";
import { Pagination } from "./Pagination";
@@ -22,18 +22,11 @@ function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef
<thead>
<tr className="border-b border-border bg-muted/50">
{cols.map((c) => (
<th
key={c.header}
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] text-center ${c.width}`}
>
{c.header}
</th>
<th key={c.header} className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] text-center ${c.width}`}>{c.header}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{children}
</tbody>
<tbody className="divide-y divide-white/[0.04]">{children}</tbody>
</table>
</div>
);
@@ -42,11 +35,7 @@ function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
const str = String(value ?? "—");
const align = center ? "text-center" : right ? "text-right" : "text-center";
return (
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${align} ${className}`} title={str}>
{str || "—"}
</div>
);
return <div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${align} ${className}`} title={str}>{str || "—"}</div>;
}
interface Props {
@@ -63,6 +52,7 @@ export function AppDetailModal({ appLabel, favicon, category, agentUuid, onClose
const [error, setError] = useState<string | null>(null);
const [isClosing, setIsClosing] = useState(false);
const [selectedIp, setSelectedIp] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const { timeRange } = useTimeFilter();
const [page, setPage] = useState(1);
@@ -73,6 +63,49 @@ export function AppDetailModal({ appLabel, favicon, category, agentUuid, onClose
setTimeout(() => { onClose(); }, 480);
};
const exportToPdf = async () => {
if (!data) return;
setIsExporting(true);
try {
const { pdf } = await import("@react-pdf/renderer");
const { default: AppPdfDocument } = await import("./AppPdfDocument");
const reportDateStr =
new Date().toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
const doc = (
<AppPdfDocument
data={data}
appLabel={appLabel}
category={category || "General Web"}
reportDateStr={reportDateStr}
/>
);
const blob = await pdf(doc).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const safeLabel = appLabel.replace(/[^a-zA-Z0-9]/g, "_");
link.download = `App_Report_${safeLabel}.pdf`;
link.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error("Failed to export PDF:", err);
alert("Failed to generate PDF report. Please try again.");
} finally {
setIsExporting(false);
}
};
useEffect(() => {
setIsLoading(true);
setError(null);
@@ -111,12 +144,23 @@ export function AppDetailModal({ appLabel, favicon, category, agentUuid, onClose
{category && <p className="text-xs text-muted-foreground">{category}</p>}
</div>
</div>
<button
onClick={handleClose}
className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all flex-shrink-0"
>
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3">
<button
onClick={exportToPdf}
disabled={isExporting || isLoading || !data}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<ArrowDownToLine className="w-3.5 h-3.5" />
)}
Export PDF
</button>
<button onClick={handleClose} className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Body */}
+158
View File
@@ -0,0 +1,158 @@
import React from "react";
import { Document, Page, Text, View } from "@react-pdf/renderer";
import { sharedStyles } from "./sharedPdfStyles";
interface AppPdfDocumentProps {
data: any;
appLabel: string;
category: string;
reportDateStr: string;
}
interface TableColumn {
header: string;
width: string;
align?: "center" | "right" | "left";
cell: (row: any) => string | number;
}
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const k = 1000;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatIndonesiaTime(dateStr: string | Date): string {
if (!dateStr) return "Never";
try {
const d = new Date(dateStr);
return d.toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
} catch (e) {
return String(dateStr);
}
}
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
return (
<View style={{ marginBottom: 15 }}>
<Text style={sharedStyles.sectionTitle}>{title}</Text>
<View style={sharedStyles.table}>
<View style={sharedStyles.tableHeader}>
{columns.map((col, idx) => (
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
{col.header}
</Text>
</View>
))}
</View>
{data.length === 0 ? (
<View style={sharedStyles.tableRow}>
<Text style={sharedStyles.emptyText}>No Data</Text>
</View>
) : (
data.map((row, rowIdx) => (
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
{columns.map((col, colIdx) => (
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
{col.cell(row)}
</Text>
</View>
))}
</View>
))
)}
</View>
</View>
);
}
export default function AppPdfDocument({
data,
appLabel,
category,
reportDateStr,
}: AppPdfDocumentProps) {
const topIps = (data.top_ips || []).slice(0, 50);
const agents = (data.agent_scorecard || []).slice(0, 50);
return (
<Document>
<Page size="A4" style={sharedStyles.page}>
{/* Header */}
<View style={sharedStyles.header}>
<View>
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
</View>
<View style={sharedStyles.titleContainer}>
<Text style={sharedStyles.title}>Application Activity Report</Text>
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
</View>
</View>
{/* Application Details */}
<Text style={sharedStyles.sectionTitle}>Application Summary</Text>
<View style={sharedStyles.grid}>
<View style={{ ...sharedStyles.gridCol, width: "50%" }}>
<Text style={sharedStyles.gridLabel}>Application Name</Text>
<Text style={sharedStyles.gridValue}>{appLabel}</Text>
</View>
<View style={{ ...sharedStyles.gridCol, width: "50%" }}>
<Text style={sharedStyles.gridLabel}>Category</Text>
<Text style={sharedStyles.gridValue}>{category || "General Web"}</Text>
</View>
</View>
{/* Bandwidth Usage */}
<Text style={sharedStyles.sectionTitle}>Traffic Metrics</Text>
<View style={sharedStyles.bandwidthGrid}>
<View style={sharedStyles.bandwidthCardCyan}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#115e59" }}>{formatBytes(data.total_download)}</Text>
</View>
<View style={sharedStyles.bandwidthCardGreen}>
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text>
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{formatBytes(data.total_upload)}</Text>
</View>
</View>
{/* Top Users */}
<RenderPdfTable
title="Top User Devices"
data={topIps}
columns={[
{ header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" },
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
{ header: "Flows", width: "10%", align: "center", cell: (r) => r.flow_count ?? 0 },
{ header: "Last Seen", width: "20%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" },
]}
/>
{/* Top Agents */}
<RenderPdfTable
title="Agent Telemetry Distribution"
data={agents}
columns={[
{ header: "Agent Node", width: "50%", cell: (r) => r.agent_label || "Unknown Agent" },
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
{ header: "Flows", width: "10%", align: "center", cell: (r) => r.flow_count ?? 0 },
]}
/>
</Page>
</Document>
);
}
+64 -52
View File
@@ -3,8 +3,9 @@
import { useState, useEffect } from "react";
import { fetchDeviceDetails, DeviceDetails, DeviceStat } from "@/lib/api";
import { fmtBytes } from "@/lib/utils";
import { Loader2, X, Monitor, Activity, Shield, Box, Server, Cpu, Clock, HardDrive, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Globe, Link2 } from "lucide-react";
import { Loader2, X, Monitor, Activity, Shield, Box, Server, Clock, ArrowDownToLine, RefreshCcw, Globe, Link2 } from "lucide-react";
import ActiveDurationDisplay from "./ActiveDurationDisplay";
import DeviceOverviewTab from "./DeviceOverviewTab";
import DeviceAppsTab from "./DeviceAppsTab";
import DeviceProtocolsTab from "./DeviceProtocolsTab";
@@ -28,12 +29,56 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
const [error, setError] = useState<string | null>(null);
const [isClosing, setIsClosing] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>("info");
const [isExporting, setIsExporting] = useState(false);
const handleClose = () => {
setIsClosing(true);
setTimeout(() => onClose(), 480);
};
const exportToPdf = async () => {
if (!data) return;
setIsExporting(true);
try {
const { pdf } = await import("@react-pdf/renderer");
const { default: DevicePdfDocument } = await import("./DevicePdfDocument");
const reportDateStr =
new Date().toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
const doc = (
<DevicePdfDocument
data={data}
ip={ip}
mac={mac}
deviceData={deviceData}
reportDateStr={reportDateStr}
/>
);
const blob = await pdf(doc).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `Device_Report_${ip || mac || "Device"}.pdf`;
link.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error("Failed to export PDF:", err);
alert("Failed to generate PDF report. Please try again.");
} finally {
setIsExporting(false);
}
};
const loadData = async (silent = false) => {
if (!silent) setIsLoading(true);
else setIsRefreshing(true);
@@ -101,9 +146,23 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
)}
</div>
</div>
<button onClick={handleClose} className="p-2.5 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all">
<X className="w-5 h-5" />
</button>
<div className="flex items-center gap-3">
<button
onClick={exportToPdf}
disabled={isExporting || isLoading || !data}
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<ArrowDownToLine className="w-3.5 h-3.5" />
)}
Export PDF
</button>
<button onClick={handleClose} className="p-2.5 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all">
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Body */}
@@ -148,54 +207,7 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
{/* Tab Content */}
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
{activeTab === "info" && (
<div className="space-y-6 animate-fade-in text-xs">
<ActiveDurationDisplay firstSeen={data.first_seen || undefined} lastSeen={data.last_seen || undefined} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-6 bg-gradient-to-br from-cyan-500/5 dark:from-cyan-500/10 to-transparent border border-cyan-100 dark:border-cyan-500/20 rounded-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ArrowDownToLine className="w-16 h-16 text-cyan-500 dark:text-cyan-400" />
</div>
<p className="text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2">
<ArrowDownToLine className="w-4 h-4" /> Total Download
</p>
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p>
</div>
<div className="p-6 bg-gradient-to-br from-emerald-500/5 dark:from-emerald-500/10 to-transparent border border-emerald-100 dark:border-emerald-500/20 rounded-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ArrowUpFromLine className="w-16 h-16 text-emerald-500 dark:text-emerald-400" />
</div>
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2">
<ArrowUpFromLine className="w-4 h-4" /> Total Upload
</p>
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
</div>
</div>
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6">
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
<Server className="w-4 h-4 text-blue-500" /> Identity & Metadata
</h4>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Cpu className="w-3.5 h-3.5" /> OS</p>
<p className="text-card-foreground font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Monitor className="w-3.5 h-3.5" /> Type</p>
<p className="text-card-foreground font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><HardDrive className="w-3.5 h-3.5" /> Manufacturer</p>
<p className="text-card-foreground font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Clock className="w-3.5 h-3.5" /> Last Seen</p>
<p className="text-card-foreground font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p>
</div>
</div>
</div>
</div>
<DeviceOverviewTab data={data} deviceData={deviceData} />
)}
{activeTab === "apps" && (
+65
View File
@@ -0,0 +1,65 @@
"use client";
import React from "react";
import { DeviceDetails, DeviceStat } from "@/lib/api";
import { fmtBytes } from "@/lib/utils";
import { Server, Cpu, Monitor, HardDrive, Clock, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import ActiveDurationDisplay from "./ActiveDurationDisplay";
interface DeviceOverviewTabProps {
data: DeviceDetails;
deviceData?: DeviceStat;
}
export default function DeviceOverviewTab({ data, deviceData }: DeviceOverviewTabProps) {
return (
<div className="space-y-6 animate-fade-in text-xs">
<ActiveDurationDisplay firstSeen={data.first_seen || undefined} lastSeen={data.last_seen || undefined} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-6 bg-gradient-to-br from-cyan-500/5 dark:from-cyan-500/10 to-transparent border border-cyan-100 dark:border-cyan-500/20 rounded-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ArrowDownToLine className="w-16 h-16 text-cyan-500 dark:text-cyan-400" />
</div>
<p className="text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2">
<ArrowDownToLine className="w-4 h-4" /> Total Download
</p>
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p>
</div>
<div className="p-6 bg-gradient-to-br from-emerald-500/5 dark:from-emerald-500/10 to-transparent border border-emerald-100 dark:border-emerald-500/20 rounded-2xl relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<ArrowUpFromLine className="w-16 h-16 text-emerald-500 dark:text-emerald-400" />
</div>
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2">
<ArrowUpFromLine className="w-4 h-4" /> Total Upload
</p>
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
</div>
</div>
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6">
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
<Server className="w-4 h-4 text-blue-500" /> Identity & Metadata
</h4>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Cpu className="w-3.5 h-3.5" /> OS</p>
<p className="text-card-foreground font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Monitor className="w-3.5 h-3.5" /> Type</p>
<p className="text-card-foreground font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><HardDrive className="w-3.5 h-3.5" /> Manufacturer</p>
<p className="text-card-foreground font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Clock className="w-3.5 h-3.5" /> Last Seen</p>
<p className="text-card-foreground font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p>
</div>
</div>
</div>
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
import React from "react";
import { Document, Page, Text, View } from "@react-pdf/renderer";
import { styles } from "./DevicePdfStyles";
interface DevicePdfDocumentProps {
data: any;
ip: string;
mac: string;
deviceData: any;
reportDateStr: string;
}
interface TableColumn {
header: string;
width: string;
align?: "center" | "right" | "left";
cell: (row: any) => string | number;
}
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const k = 1000;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}
function formatIndonesiaTime(dateStr: string | Date): string {
if (!dateStr) return "Never";
try {
const d = new Date(dateStr);
return d.toLocaleString("id-ID", {
timeZone: "Asia/Jakarta",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}) + " WIB";
} catch (e) {
return String(dateStr);
}
}
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
return (
<View style={{ marginBottom: 15 }}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.table}>
<View style={styles.tableHeader}>
{columns.map((col, idx) => (
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...styles.tableHeaderCell, textAlign: col.align || "left" }}>
{col.header}
</Text>
</View>
))}
</View>
{data.length === 0 ? (
<View style={styles.tableRow}>
<Text style={styles.emptyText}>No Data</Text>
</View>
) : (
data.map((row, rowIdx) => (
<View key={rowIdx} style={styles.tableRow} wrap={false}>
{columns.map((col, colIdx) => (
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
<Text style={{ ...styles.tableCell, textAlign: col.align || "left" }}>
{col.cell(row)}
</Text>
</View>
))}
</View>
))
)}
</View>
</View>
);
}
export default function DevicePdfDocument({
data,
ip,
mac,
deviceData,
reportDateStr,
}: DevicePdfDocumentProps) {
// Compliance check: show all data up to 50 threshold
const topApps = (data.apps || []).slice(0, 50);
const topProtocols = (data.protocols || []).slice(0, 50);
const topDomains = (data.domains || []).slice(0, 50);
const detectedThreats = (data.threats || []).slice(0, 50);
const flows = (data.flows || []).slice(0, 50);
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.logoText}>Deep Package Inspection</Text>
<Text style={styles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
</View>
<View style={styles.titleContainer}>
<Text style={styles.title}>Device Analysis Report</Text>
<Text style={styles.subtitle}>Generated at: {reportDateStr}</Text>
</View>
</View>
{/* Device Information */}
<Text style={styles.sectionTitle}>Device Identification</Text>
<View style={styles.grid}>
<View style={styles.gridCol}>
<Text style={styles.gridLabel}>IP Address</Text>
<Text style={styles.gridValue}>{ip || "N/A"}</Text>
</View>
<View style={styles.gridCol}>
<Text style={styles.gridLabel}>MAC Address</Text>
<Text style={styles.gridValue}>{mac || "N/A"}</Text>
</View>
<View style={{ ...styles.gridCol, width: "50%" }}>
<Text style={styles.gridLabel}>Device OS</Text>
<Text style={styles.gridValue}>{data.os_label || deviceData?.os_label || "Unknown"}</Text>
</View>
<View style={styles.gridCol}>
<Text style={styles.gridLabel}>Manufacturer</Text>
<Text style={styles.gridValue}>{data.manufacturer || deviceData?.manufacturer || "Unknown"}</Text>
</View>
<View style={styles.gridCol}>
<Text style={styles.gridLabel}>Source Agent</Text>
<Text style={styles.gridValue}>{data.agent_label || "N/A"}</Text>
</View>
<View style={{ ...styles.gridCol, width: "50%" }}>
<Text style={styles.gridLabel}>Last Seen</Text>
<Text style={styles.gridValue}>
{data.last_seen || deviceData?.last_seen
? formatIndonesiaTime(data.last_seen || deviceData?.last_seen)
: "Never"}
</Text>
</View>
</View>
{/* Bandwidth Usage */}
<Text style={styles.sectionTitle}>Bandwidth Metrics</Text>
<View style={styles.bandwidthGrid}>
<View style={styles.bandwidthCardCyan}>
<Text style={{ ...styles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text>
<Text style={{ ...styles.bandwidthValue, color: "#115e59" }}>{formatBytes(data.total_download)}</Text>
</View>
<View style={styles.bandwidthCardGreen}>
<Text style={{ ...styles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text>
<Text style={{ ...styles.bandwidthValue, color: "#166534" }}>{formatBytes(data.total_upload)}</Text>
</View>
</View>
{/* Security Threats Table */}
<RenderPdfTable
title={`Security Threats (${detectedThreats.length})`}
data={detectedThreats}
columns={[
{ header: "Time", width: "25%", cell: (r) => r.detected_at ? formatIndonesiaTime(r.detected_at) : "N/A" },
{ header: "Threat Type / Description", width: "35%", cell: (r) => r.threat_type || "Security Alert" },
{ header: "Severity", width: "15%", align: "center", cell: (r) => (r.severity || "UNKNOWN").toUpperCase() },
{ header: "Target IP", width: "25%", cell: (r) => r.dst_ip || r.ip_address || "—" },
]}
/>
{/* Top Applications */}
<RenderPdfTable
title="Top Applications (By Traffic)"
data={topApps}
columns={[
{ header: "Application", width: "50%", cell: (r) => r.app_label || "Unknown" },
{ header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) },
]}
/>
{/* Top Network Protocols */}
<RenderPdfTable
title="Top Protocols"
data={topProtocols}
columns={[
{ header: "Protocol / Port", width: "50%", cell: (r) => r.protocol || "—" },
{ header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) },
]}
/>
{/* Top Visited Domains */}
<RenderPdfTable
title="Top Domains"
data={topDomains}
columns={[
{ header: "Domain", width: "50%", cell: (r) => r.domain || "—" },
{ header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) },
]}
/>
{/* Recent Network Flows */}
<RenderPdfTable
title="Recent Network Flows"
data={flows}
columns={[
{ header: "Source IP", width: "20%", cell: (r) => r.src_ip || "—" },
{ header: "Destination IP", width: "20%", cell: (r) => r.dst_ip || "—" },
{ header: "Port", width: "10%", align: "center", cell: (r) => r.dst_port || "—" },
{ header: "App / Protocol", width: "20%", cell: (r) => r.app_label || r.protocol || "Unknown" },
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
]}
/>
</Page>
</Document>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { StyleSheet } from "@react-pdf/renderer";
export const styles = StyleSheet.create({
page: {
padding: 30,
fontSize: 9,
fontFamily: "Helvetica",
color: "#334155",
backgroundColor: "#ffffff",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
borderBottomWidth: 1.5,
borderBottomColor: "#3b82f6",
paddingBottom: 12,
marginBottom: 15,
},
logoText: {
fontSize: 16,
fontWeight: "bold",
color: "#1e3a8a",
},
logoSubtext: {
fontSize: 8,
color: "#64748b",
marginTop: 2,
},
titleContainer: {
alignItems: "flex-end",
},
title: {
fontSize: 12,
fontWeight: "bold",
color: "#0f172a",
},
subtitle: {
fontSize: 8,
color: "#64748b",
marginTop: 2,
},
sectionTitle: {
fontSize: 10,
fontWeight: "bold",
color: "#1e3a8a",
backgroundColor: "#f8fafc",
padding: "4 8",
marginTop: 15,
marginBottom: 8,
borderLeftWidth: 3,
borderLeftColor: "#3b82f6",
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
marginBottom: 10,
},
gridCol: {
width: "25%",
marginBottom: 8,
},
gridLabel: {
fontSize: 7,
color: "#64748b",
textTransform: "uppercase",
marginBottom: 2,
},
gridValue: {
fontSize: 8.5,
fontWeight: "bold",
color: "#0f172a",
},
bandwidthGrid: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 12,
},
bandwidthCardCyan: {
width: "48%",
padding: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: "#e2e8f0",
backgroundColor: "#f0fdfa",
},
bandwidthCardGreen: {
width: "48%",
padding: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: "#e2e8f0",
backgroundColor: "#f0fdf4",
},
bandwidthLabel: {
fontSize: 7.5,
fontWeight: "bold",
marginBottom: 3,
},
bandwidthValue: {
fontSize: 15,
fontWeight: "bold",
},
table: {
width: "100%",
marginBottom: 10,
},
tableHeader: {
flexDirection: "row",
backgroundColor: "#f1f5f9",
borderBottomWidth: 1,
borderBottomColor: "#cbd5e1",
padding: "4 6",
},
tableHeaderCell: {
fontSize: 7.5,
fontWeight: "bold",
color: "#475569",
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: "#f1f5f9",
padding: "4 6",
},
tableCell: {
fontSize: 7.5,
color: "#334155",
},
alertBox: {
padding: 8,
borderRadius: 6,
backgroundColor: "#fef2f2",
borderWidth: 1,
borderColor: "#fecaca",
marginBottom: 10,
},
alertTitle: {
fontSize: 8.5,
fontWeight: "bold",
color: "#991b1b",
marginBottom: 2,
},
alertText: {
fontSize: 7.5,
color: "#7f1d1d",
},
emptyText: {
fontSize: 8,
color: "#64748b",
fontStyle: "italic",
padding: 6,
},
});
+1 -1
View File
@@ -118,7 +118,7 @@ export function HelpCenterFAB() {
Read Full User Guide
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Panduan membaca data per halaman, tab, dan kolom tabel
Guide on reading data across pages, tabs, and table columns
</p>
</div>
<ArrowRight className="h-4 w-4 text-blue-500 shrink-0 group-hover:translate-x-0.5 transition-transform" />
+153
View File
@@ -0,0 +1,153 @@
import { StyleSheet } from "@react-pdf/renderer";
export const sharedStyles = StyleSheet.create({
page: {
padding: 30,
fontSize: 9,
fontFamily: "Helvetica",
color: "#334155",
backgroundColor: "#ffffff",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
borderBottomWidth: 1.5,
borderBottomColor: "#3b82f6",
paddingBottom: 12,
marginBottom: 15,
},
logoText: {
fontSize: 16,
fontWeight: "bold",
color: "#1e3a8a",
},
logoSubtext: {
fontSize: 8,
color: "#64748b",
marginTop: 2,
},
titleContainer: {
alignItems: "flex-end",
},
title: {
fontSize: 12,
fontWeight: "bold",
color: "#0f172a",
},
subtitle: {
fontSize: 8,
color: "#64748b",
marginTop: 2,
},
sectionTitle: {
fontSize: 10,
fontWeight: "bold",
color: "#1e3a8a",
backgroundColor: "#f8fafc",
padding: "4 8",
marginTop: 15,
marginBottom: 8,
borderLeftWidth: 3,
borderLeftColor: "#3b82f6",
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
marginBottom: 10,
},
gridCol: {
width: "25%",
marginBottom: 8,
},
gridLabel: {
fontSize: 7,
color: "#64748b",
textTransform: "uppercase",
marginBottom: 2,
},
gridValue: {
fontSize: 8.5,
fontWeight: "bold",
color: "#0f172a",
},
bandwidthGrid: {
flexDirection: "row",
justifyContent: "space-between",
marginBottom: 12,
},
bandwidthCardCyan: {
width: "48%",
padding: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: "#e2e8f0",
backgroundColor: "#f0fdfa",
},
bandwidthCardGreen: {
width: "48%",
padding: 10,
borderRadius: 6,
borderWidth: 1,
borderColor: "#e2e8f0",
backgroundColor: "#f0fdf4",
},
bandwidthLabel: {
fontSize: 7.5,
fontWeight: "bold",
marginBottom: 3,
},
bandwidthValue: {
fontSize: 15,
fontWeight: "bold",
},
table: {
width: "100%",
marginBottom: 10,
},
tableHeader: {
flexDirection: "row",
backgroundColor: "#f1f5f9",
borderBottomWidth: 1,
borderBottomColor: "#cbd5e1",
padding: "4 6",
},
tableHeaderCell: {
fontSize: 7.5,
fontWeight: "bold",
color: "#475569",
},
tableRow: {
flexDirection: "row",
borderBottomWidth: 1,
borderBottomColor: "#f1f5f9",
padding: "4 6",
},
tableCell: {
fontSize: 7.5,
color: "#334155",
},
alertBox: {
padding: 8,
borderRadius: 6,
backgroundColor: "#fef2f2",
borderWidth: 1,
borderColor: "#fecaca",
marginBottom: 10,
},
alertTitle: {
fontSize: 8.5,
fontWeight: "bold",
color: "#991b1b",
marginBottom: 2,
},
alertText: {
fontSize: 7.5,
color: "#7f1d1d",
},
emptyText: {
fontSize: 8,
color: "#64748b",
fontStyle: "italic",
padding: 6,
},
});
+7 -3
View File
@@ -1,5 +1,9 @@
"use server";
if (typeof globalThis.crypto === 'undefined') {
globalThis.crypto = require('crypto');
}
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import jwt from "jsonwebtoken";
@@ -203,7 +207,7 @@ export async function createAgent(agentId: string, label: string) {
try {
const API_KEY = getApiKey();
if (API_KEY && API_KEY.startsWith("sk_db_")) {
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan membuat Agents." };
return { success: false, message: "Action denied: Site-scoped API Key is not allowed to create Agents." };
}
const url = agentId
@@ -231,7 +235,7 @@ export async function updateAgent(agentId: string, label: string) {
try {
const API_KEY = getApiKey();
if (API_KEY && API_KEY.startsWith("sk_db_")) {
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan mengubah Agents." };
return { success: false, message: "Action denied: Site-scoped API Key is not allowed to modify Agents." };
}
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`, {
@@ -255,7 +259,7 @@ export async function deleteAgent(agentId: string) {
try {
const API_KEY = getApiKey();
if (API_KEY && API_KEY.startsWith("sk_db_")) {
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan menghapus Agents." };
return { success: false, message: "Action denied: Site-scoped API Key is not allowed to delete Agents." };
}
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}`, {
+163 -143
View File
@@ -31,34 +31,54 @@ export interface PageGuide {
// ── FAQ (used in HelpCenterFAB quick panel) ──────────────────────────────────
export const FAQS: FAQItem[] = [
{
question: "Kenapa data Threats kosong (0)?",
question: "Why is the Threats data empty (0)?",
answer:
"Jika indikator Threats menunjukkan angka 0, artinya jaringan Anda aman dalam rentang waktu yang dipilih. Sistem tidak mendeteksi adanya paket data yang cocok dengan profil serangan yang diketahui.",
"If the Threats indicator shows 0, it means your network is secure within the selected time range. The system did not detect any data packets matching known attack profiles.",
},
{
question: "Apa perbedaan Events dan Threats?",
question: "What is the difference between Events and Threats?",
answer:
"Events adalah semua log aktivitas koneksi biasa yang tercatat di jaringan (termasuk yang aman). Threats adalah sekumpulan Events khusus yang telah difilter dan diklasifikasikan sebagai aktivitas berbahaya oleh sistem Deep Packet Inspection (DPI).",
"Events are all standard connection activity logs recorded on the network (including secure ones). Threats are a subset of specific Events that have been filtered and classified as malicious activity by the Deep Packet Inspection (DPI) engine.",
},
{
question: "Bagaimana cara membaca Heatmap Geo Traffic?",
question: "How do I read the Geo Traffic Heatmap?",
answer:
"Warna biru muda menunjukkan trafik rendah, sedangkan warna biru pekat/tua menunjukkan volume trafik (download/upload) yang sangat tinggi dari/ke negara tersebut.",
"Light blue indicates low traffic, while deep/dark blue indicates a very high traffic volume (download/upload) to or from that country.",
},
{
question: "Berapa lama data disimpan?",
question: "What do the Online/Offline indicators mean for agents?",
answer:
"Database sistem dikonfigurasi untuk secara otomatis menghapus data telemetri jaringan yang usianya lebih dari 7 hari untuk menjaga performa sistem.",
"An agent is marked Online if it has successfully transmitted data within the last 12 hours. An Offline agent means no data packets have been received beyond that threshold.",
},
{
question: "Apa arti indikator Online/Offline pada agen?",
question: "Why is Download larger than Upload?",
answer:
"Agen berstatus Online jika berhasil mengirim data dalam 12 jam terakhir. Agen Offline berarti tidak ada paket data yang diterima melebihi batas waktu tersebut.",
"This is normal behavior. Network users generally download (streaming, web browsing) significantly more than they upload. If Upload is larger, it could indicate data exfiltration and should be investigated.",
},
{
question: "Mengapa Download lebih besar dari Upload?",
question: "How is threat severity (Critical, High, Medium, Low) determined?",
answer:
"Ini adalah kondisi normal. Pengguna di jaringan umumnya mengunduh (streaming, web browsing) jauh lebih banyak daripada mengunggah. Jika Upload lebih besar, hal itu bisa mengindikasikan data exfiltration dan perlu diselidiki.",
"Severity is determined based on standard threat signatures. For example, botnet Command and Control (C2) or malware connections are classified as Critical or High, while protocol policy violations (like using plaintext HTTP or Telnet) are classified as Medium or Low.",
},
{
question: "Why do some devices show \"Unknown\" for operating system or hostname?",
answer:
"Hostnames are resolved via local DNS queries, and operating systems are identified passively using HTTP User-Agent and DHCP Fingerprints. If a device has not sent standard HTTP traffic or has recently connected without requesting a new IP, the system will display 'Unknown'.",
},
{
question: "How does the \"View As Agent\" mode affect the dashboard data?",
answer:
"When 'View As Agent' mode is enabled, the entire data scope on the dashboard is locked to that specific agent. You will only see active devices, DNS logs, traffic flows, and threats detected specifically by that agent, rather than the aggregate data of the entire site.",
},
{
question: "What should I do if the Encryption Audit flags a connection as \"High Risk\"?",
answer:
"A 'High Risk' status is typically triggered by the use of legacy encryption versions such as TLS 1.0 or TLS 1.1, which are vulnerable to eavesdropping. It is recommended to update the destination server configuration or client systems to use at least TLS 1.2 or TLS 1.3.",
},
{
question: "Why does the dashboard only display data up to the last 7 days?",
answer:
"According to the Deep Package Inspection data retention policy to maintain database capacity and query performance, network telemetry data older than 7 days is automatically deleted on a regular basis.",
},
];
@@ -69,26 +89,26 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Overview",
url: "/",
icon: "🏠",
summary: "Ringkasan kondisi jaringan secara keseluruhan dalam satu tampilan.",
summary: "Overall network condition summary in a single view.",
sections: [
{
heading: "Kartu KPI (Header)",
heading: "KPI Cards (Header)",
columns: [
{ column: "Total Flows", description: "Jumlah sesi koneksi jaringan yang terpantau dalam rentang waktu aktif." },
{ column: "Total Download", description: "Akumulasi data yang diterima oleh semua perangkat di site aktif." },
{ column: "Total Upload", description: "Akumulasi data yang dikirim keluar dari site aktif." },
{ column: "Active Devices", description: "Jumlah perangkat unik yang terdeteksi aktif dalam rentang waktu aktif." },
{ column: "Total Flows", description: "Number of network connection sessions monitored within the active time range." },
{ column: "Total Download", description: "Accumulated data received by all devices on the active site." },
{ column: "Total Upload", description: "Accumulated data sent out from the active site." },
{ column: "Active Devices", description: "Number of unique devices detected active within the active time range." },
],
},
{
heading: "Globe Map",
content:
"Peta bola dunia interaktif. Garis bercahaya merepresentasikan arus trafik dari site Anda ke negara tujuan. Warna garis menunjukkan intensitas trafik. Hover ke garis untuk melihat nama negara dan volume data. Klik dan seret untuk memutar globe.",
"Interactive global map. Glowing lines represent the flow of traffic from your site to the destination country. Line colors indicate traffic intensity. Hover over a line to see the country name and data volume. Click and drag to rotate the globe.",
},
{
heading: "Top Applications & Top Protocols",
content:
"Widget di bawah globe menampilkan 7 aplikasi dan protokol dengan volume trafik tertinggi. Bar horizontal merepresentasikan perbandingan volume relatif antar entri.",
"Widgets below the globe display the top 7 applications and protocols with the highest traffic volume. The horizontal bars represent relative volume comparisons between entries.",
},
],
},
@@ -97,33 +117,33 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Agents Inventory",
url: "/agents",
icon: "📡",
summary: "Daftar dan status semua Network Agent yang terdaftar di site Anda.",
summary: "List and status of all Network Agents registered at your site.",
sections: [
{
heading: "Kartu Statistik",
heading: "Statistic Cards",
columns: [
{ column: "Total Agents", description: "Jumlah Network Agent yang terdaftar di site aktif." },
{ column: "Online Agents", description: "Agen yang berhasil mengirim data dalam 12 jam terakhir." },
{ column: "Offline Agents", description: "Agen yang tidak terdeteksi aktif." },
{ column: "Avg. Historical Uptime", description: "Rata-rata persentase waktu aktif seluruh agen sepanjang data 7 hari tersimpan." },
{ column: "Total Agents", description: "Total number of Network Agents registered on the active site." },
{ column: "Online Agents", description: "Agents that successfully sent data within the last 12 hours." },
{ column: "Offline Agents", description: "Agents not detected active." },
{ column: "Avg. Historical Uptime", description: "Average uptime percentage of all agents over the 7 days of stored data." },
],
},
{
heading: "Agent Location & Topology Map",
content:
"Peta Indonesia interaktif. Titik hijau = agen Online. Titik merah = agen Offline. Garis biru = aliran trafik antar agen yang terdeteksi. Hover ke titik untuk melihat detail agen. Hover ke garis untuk melihat detail flow (agen asal, tujuan, volume data). Panel biru di bawah peta menampilkan agen yang belum memiliki koordinat GPS — klik chip agen untuk menambahkan koordinat secara manual.",
"Interactive map of Indonesia. Green dots represent Online agents, red dots represent Offline agents. Blue lines show detected traffic flows between agents. Hover over a dot to view agent details. Hover over a line to view flow details (source agent, destination agent, data volume). The blue panel below the map shows agents lacking GPS coordinates — click the agent chip to manually add coordinates.",
},
{
heading: "Tabel: Provisioned Network Agents",
heading: "Table: Provisioned Network Agents",
columns: [
{ column: "UUID / Serial", description: "Identitas unik perangkat agen hardware." },
{ column: "Label", description: "Nama lokasi atau deskripsi yang diberikan untuk agen ini." },
{ column: "Provisioned", description: "Apakah agen sudah diaktifkan dan didaftarkan di sistem." },
{ column: "Activated", description: "Apakah agen sudah terhubung dan pernah mengirim data." },
{ column: "Status", description: "Online (hijau) atau Offline (merah) beserta waktu terakhir terlihat." },
{ column: "Historical Uptime", description: "Persentase waktu aktif selama 7 hari data tersimpan." },
{ column: "Data Size", description: "Total ukuran data yang dikumpulkan agen ini di database sistem." },
{ column: "Actions", description: "Tombol aksi: Detail, Koordinat GPS, Visibilitas, Delete." },
{ column: "UUID / Serial", description: "Unique identifier of the hardware agent device." },
{ column: "Label", description: "Location name or description provided for this agent." },
{ column: "Provisioned", description: "Whether the agent has been activated and registered in the system." },
{ column: "Activated", description: "Whether the agent has connected and ever transmitted data." },
{ column: "Status", description: "Online (green) or Offline (red) along with the last seen timestamp." },
{ column: "Historical Uptime", description: "Uptime percentage during the 7 days of stored data." },
{ column: "Data Size", description: "Total size of data collected by this agent in the system database." },
{ column: "Actions", description: "Action buttons: Details, GPS Coordinates, Visibility, Delete." },
],
},
],
@@ -133,26 +153,26 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Flows",
url: "/flows",
icon: "🔀",
summary: "Daftar semua sesi koneksi jaringan aktif yang terdeteksi oleh DPI.",
summary: "List of all active network connection sessions detected by the DPI.",
sections: [
{
heading: "Filter Bar",
content:
"Filter di bagian atas tabel: Source IP, Destination IP, Protocol, dan App / Domain. Kombinasikan beberapa filter untuk mempersempit hasil pencarian.",
"Filters at the top of the table: Source IP, Destination IP, Protocol, and App / Domain. Combine multiple filters to narrow search results.",
},
{
heading: "Tabel: Active Flows",
heading: "Table: Active Flows",
columns: [
{ column: "#", description: "Nomor urut baris dalam halaman saat ini." },
{ column: "Last Seen", description: "Waktu terakhir paket dari sesi ini diterima (zona waktu Indonesia)." },
{ column: "Flow ID", description: "Identitas unik sesi koneksi yang dibuat oleh DPI." },
{ column: "Src IP", description: "Alamat IP sumber — perangkat atau server yang memulai koneksi." },
{ column: "Dst IP", description: "Alamat IP tujuan. Hover untuk melihat nama perangkat jika diketahui." },
{ column: "Dst Port", description: "Port tujuan. Hover untuk melihat nama layanan (443=HTTPS, 53=DNS, dll.)." },
{ column: "Protocol", description: "Protokol jaringan yang digunakan pada sesi ini (TCP, UDP, QUIC, dll.)." },
{ column: "App / Domain", description: "Aplikasi atau nama domain yang teridentifikasi oleh mesin DPI." },
{ column: "Download", description: "Volume data yang diterima dari tujuan ke sumber (dalam KB/MB)." },
{ column: "Upload", description: "Volume data yang dikirim dari sumber ke tujuan." },
{ column: "#", description: "Row sequence number on the current page." },
{ column: "Last Seen", description: "Last time a packet from this session was received (Indonesia timezone)." },
{ column: "Flow ID", description: "Unique identifier of the connection session created by the DPI." },
{ column: "Src IP", description: "Source IP address — the device or server initiating the connection." },
{ column: "Dst IP", description: "Destination IP address. Hover to see the device name if known." },
{ column: "Dst Port", description: "Destination port. Hover to view the service name (443=HTTPS, 53=DNS, etc.)." },
{ column: "Protocol", description: "Network protocol used in this session (TCP, UDP, QUIC, etc.)." },
{ column: "App / Domain", description: "Application or domain name identified by the DPI engine." },
{ column: "Download", description: "Volume of data received from destination to source (in KB/MB)." },
{ column: "Upload", description: "Volume of data sent from source to destination." },
],
},
],
@@ -162,17 +182,17 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Apps",
url: "/apps",
icon: "📦",
summary: "Daftar semua aplikasi yang teridentifikasi oleh DPI beserta volume trafik.",
summary: "List of all applications identified by the DPI along with their traffic volume.",
sections: [
{
heading: "Tabel: Top Applications",
heading: "Table: Top Applications",
columns: [
{ column: "Application", description: "Nama aplikasi yang teridentifikasi (YouTube, Zoom, WhatsApp, dll.)." },
{ column: "Download", description: "Total data yang diunduh melalui aplikasi ini dalam rentang waktu aktif." },
{ column: "Upload", description: "Total data yang diunggah melalui aplikasi ini." },
{ column: "Total Traffic", description: "Gabungan download + upload untuk aplikasi ini." },
{ column: "Devices", description: "Jumlah perangkat unik yang menggunakan aplikasi ini." },
{ column: "Flows", description: "Jumlah sesi koneksi aktif dari aplikasi ini." },
{ column: "Application", description: "Name of the identified application (YouTube, Zoom, WhatsApp, etc.)." },
{ column: "Download", description: "Total data downloaded through this application within the active time range." },
{ column: "Upload", description: "Total data uploaded through this application." },
{ column: "Total Traffic", description: "Combined download + upload for this application." },
{ column: "Devices", description: "Number of unique devices using this application." },
{ column: "Flows", description: "Number of active connection sessions for this application." },
],
},
],
@@ -182,17 +202,17 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Devices",
url: "/devices",
icon: "💻",
summary: "Inventaris semua perangkat yang terdeteksi aktif di jaringan.",
summary: "Inventory of all devices detected active on the network.",
sections: [
{
heading: "Tabel: Device Inventory",
heading: "Table: Device Inventory",
columns: [
{ column: "IP Address", description: "Alamat IP perangkat di jaringan lokal." },
{ column: "MAC Address", description: "Identitas fisik kartu jaringan perangkat (hardware address)." },
{ column: "Hostname", description: "Nama perangkat jika tersedia dari resolver DNS lokal." },
{ column: "Agent", description: "Network Agent yang mendeteksi dan memantau perangkat ini." },
{ column: "Last Seen", description: "Kapan terakhir kali perangkat ini aktif mengirim atau menerima trafik." },
{ column: "Download / Upload", description: "Volume trafik total dari dan ke perangkat ini." },
{ column: "IP Address", description: "IP address of the device on the local network." },
{ column: "MAC Address", description: "Physical identity of the device's network card (hardware address)." },
{ column: "Hostname", description: "Device name if available from the local DNS resolver." },
{ column: "Agent", description: "Network Agent detecting and monitoring this device." },
{ column: "Last Seen", description: "When the device was last active sending or receiving traffic." },
{ column: "Download / Upload", description: "Total traffic volume to and from this device." },
],
},
],
@@ -202,17 +222,17 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "DNS",
url: "/dns",
icon: "🌐",
summary: "Log query DNS yang terdeteksi di jaringan.",
summary: "DNS query log detected on the network.",
sections: [
{
heading: "Tabel: DNS Queries",
heading: "Table: DNS Queries",
columns: [
{ column: "Domain", description: "Nama domain yang di-query oleh perangkat di jaringan." },
{ column: "Query Type", description: "Jenis query DNS: A (IPv4), AAAA (IPv6), MX (mail), CNAME (alias), dll." },
{ column: "Client IP", description: "Alamat IP perangkat yang melakukan query DNS ini." },
{ column: "Response", description: "Alamat IP yang dikembalikan oleh server DNS sebagai jawaban." },
{ column: "Count", description: "Frekuensi query domain ini dalam rentang waktu aktif." },
{ column: "Last Seen", description: "Waktu terakhir query domain ini terjadi." },
{ column: "Domain", description: "Domain name queried by devices on the network." },
{ column: "Query Type", description: "Type of DNS query: A (IPv4), AAAA (IPv6), MX (mail), CNAME (alias), etc." },
{ column: "Client IP", description: "IP address of the device making this DNS query." },
{ column: "Response", description: "IP address returned by the DNS server as the answer." },
{ column: "Count", description: "Frequency of queries for this domain within the active time range." },
{ column: "Last Seen", description: "Timestamp when this domain query last occurred." },
],
},
],
@@ -222,21 +242,21 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Traffic Categories",
url: "/network-intelligence",
icon: "📊",
summary: "Klasifikasi trafik berdasarkan kategori aplikasi hasil analisis DPI.",
summary: "Traffic classification based on application categories from DPI analysis.",
sections: [
{
heading: "Kategori Utama",
heading: "Main Categories",
content:
"Menampilkan bagan distribusi trafik per kategori utama seperti Streaming, Social Media, Web, Email, Cloud, File Transfer, dll. serta visualisasi proporsi trafik menggunakan Pie Chart.",
"Displays traffic distribution charts for major categories such as Streaming, Social Media, Web, Email, Cloud, File Transfer, etc., and visualizes traffic proportions using a Pie Chart.",
},
{
heading: "Tabel Kategori Aplikasi",
heading: "Application Categories Table",
columns: [
{ column: "Category", description: "Nama kategori aplikasi jaringan yang diidentifikasi." },
{ column: "Download", description: "Volume data yang diunduh pada kategori ini." },
{ column: "Upload", description: "Volume data yang diunggah pada kategori ini." },
{ column: "Total Traffic", description: "Gabungan volume download dan upload." },
{ column: "Devices", description: "Jumlah perangkat unik yang menggunakan kategori ini." },
{ column: "Category", description: "Name of the identified network application category." },
{ column: "Download", description: "Volume of data downloaded in this category." },
{ column: "Upload", description: "Volume of data uploaded in this category." },
{ column: "Total Traffic", description: "Combined download and upload volume." },
{ column: "Devices", description: "Number of unique devices using this category." },
],
},
],
@@ -246,27 +266,27 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "DPI MetaData",
url: "/dpi-analytics",
icon: "🔌",
summary: "Analisis metadata mentah hasil inspeksi paket mendalam (DPI) dan sidik jari perangkat.",
summary: "Raw metadata analysis from Deep Packet Inspection (DPI) and device fingerprinting.",
sections: [
{
heading: "Tab: Hostnames & Servers",
content: "Log domain eksternal, IP server, port, dan agen pendeteksi. Membantu mengidentifikasi tujuan koneksi perangkat.",
content: "Logs external domains, server IPs, ports, and detecting agents. Helps identify device connection destinations.",
},
{
heading: "Tab: Client Fingerprints",
content: "Menampilkan data sidik jari sistem operasi (Discovery OS), dhcpFingerprints, dan HTTP User Agent untuk mengidentifikasi jenis perangkat yang aktif.",
content: "Displays operating system fingerprint data (Discovery OS), dhcpFingerprints, and HTTP User Agent to identify active device types.",
},
{
heading: "Tab: Specific Protocols",
content: "Log metadata protokol khusus seperti versi server SSH yang digunakan dan Bittorrent Hashes untuk mengaudit torrenting.",
content: "Logs specific protocol metadata such as the SSH server version used and BitTorrent Hashes to audit torrenting.",
},
{
heading: "Tabel Detail Metadata",
heading: "Metadata Detail Table",
columns: [
{ column: "Value / OS / Domain", description: "Nilai metadata, nama domain, User Agent, atau nama sistem operasi." },
{ column: "Details / Version", description: "Informasi detail tambahan (seperti versi SSH atau detail OS)." },
{ column: "Count", description: "Frekuensi kemunculan metadata ini di jaringan." },
{ column: "Last Seen", description: "Waktu terakhir metadata ini terdeteksi di database." },
{ column: "Value / OS / Domain", description: "Metadata value, domain name, User Agent, or operating system name." },
{ column: "Details / Version", description: "Additional detailed information (such as SSH version or OS details)." },
{ column: "Count", description: "Frequency of this metadata appearing on the network." },
{ column: "Last Seen", description: "Last time this metadata was detected in the database." },
],
},
],
@@ -276,15 +296,15 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Geo Traffic",
url: "/geography",
icon: "🗺️",
summary: "Distribusi trafik berdasarkan negara tujuan koneksi.",
summary: "Traffic distribution based on the connection's destination country.",
sections: [
{
heading: "Tabel: Traffic by Country",
heading: "Table: Traffic by Country",
columns: [
{ column: "Country", description: "Negara tujuan koneksi yang terdeteksi oleh mesin geolokasi." },
{ column: "Flag", description: "Bendera negara sebagai identifikasi visual." },
{ column: "Download / Upload", description: "Volume trafik yang dikirim ke atau diterima dari negara tersebut." },
{ column: "Flows", description: "Jumlah sesi koneksi aktif yang menuju negara tersebut." },
{ column: "Country", description: "Connection destination country detected by the geolocation engine." },
{ column: "Flag", description: "Country flag for visual identification." },
{ column: "Download / Upload", description: "Traffic volume sent to or received from that country." },
{ column: "Flows", description: "Number of active connection sessions heading to that country." },
],
},
],
@@ -294,26 +314,26 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Threat Intelligence",
url: "/intelligence",
icon: "🛡️",
summary: "Deteksi ancaman keamanan jaringan secara otomatis oleh sistem DPI.",
summary: "Automatic detection of network security threats by the DPI system.",
sections: [
{
heading: "Kategori Ancaman (Sidebar Kiri)",
heading: "Threat Categories (Left Sidebar)",
items: [
{ label: "Crypto Mining", description: "Perangkat yang terdeteksi menambang cryptocurrency tanpa izin." },
{ label: "Botnet Command & Control", description: "Koneksi ke server Command and Control yang diketahui berbahaya." },
{ label: "Malware", description: "Trafik yang cocok dengan signature malware yang terdaftar." },
{ label: "Phishing", description: "Akses ke domain yang teridentifikasi sebagai situs phishing." },
{ label: "Crypto Mining", description: "Devices detected mining cryptocurrency without authorization." },
{ label: "Botnet Command & Control", description: "Connections to known malicious Command and Control servers." },
{ label: "Malware", description: "Traffic matching registered malware signatures." },
{ label: "Phishing", description: "Access to domains identified as phishing sites." },
],
},
{
heading: "Tabel Detail Ancaman",
heading: "Threat Detail Table",
columns: [
{ column: "Threat Level", description: "Tingkat keparahan: Critical, High, Medium, atau Low." },
{ column: "Source IP", description: "Alamat IP perangkat di jaringan Anda yang terlibat." },
{ column: "Destination", description: "IP atau domain tujuan yang teridentifikasi berbahaya." },
{ column: "First Seen", description: "Kapan pertama kali ancaman ini terdeteksi dalam database." },
{ column: "Last Seen", description: "Kapan terakhir kali aktivitas mencurigakan ini terpantau." },
{ column: "Evidence", description: "Detail teknis yang menjadi dasar sistem mengklasifikasikan ini sebagai ancaman." },
{ column: "Threat Level", description: "Severity level: Critical, High, Medium, or Low." },
{ column: "Source IP", description: "IP address of the involved device on your network." },
{ column: "Destination", description: "Destination IP or domain identified as malicious." },
{ column: "First Seen", description: "When this threat was first detected in the database." },
{ column: "Last Seen", description: "When this suspicious activity was last observed." },
{ column: "Evidence", description: "Technical details forming the basis for the system classifying this as a threat." },
],
},
],
@@ -323,16 +343,16 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Events",
url: "/events",
icon: "📋",
summary: "Log semua kejadian penting yang dicatat oleh sistem.",
summary: "Log of all key events recorded by the system.",
sections: [
{
heading: "Tabel: Event Log",
heading: "Table: Event Log",
columns: [
{ column: "Timestamp", description: "Waktu kejadian terjadi dalam zona waktu Indonesia." },
{ column: "Severity", description: "Tingkat keparahan: Info, Warning, atau Critical." },
{ column: "Agent", description: "Network Agent yang mendeteksi dan mencatat kejadian ini." },
{ column: "Description", description: "Deskripsi singkat kejadian yang terjadi." },
{ column: "Details", description: "Klik untuk membuka panel detail lengkap kejadian." },
{ column: "Timestamp", description: "Time the event occurred in Indonesia timezone." },
{ column: "Severity", description: "Severity level: Info, Warning, or Critical." },
{ column: "Agent", description: "Network Agent that detected and recorded this event." },
{ column: "Description", description: "Brief description of the event that occurred." },
{ column: "Details", description: "Click to open the full event details panel." },
],
},
],
@@ -342,22 +362,22 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Encryption Audit (TLS)",
url: "/security-audit",
icon: "🔐",
summary: "Audit keamanan enkripsi TLS di seluruh jaringan yang terpantau.",
summary: "Security audit of TLS encryption across the monitored network.",
sections: [
{
heading: "Tabel: TLS Versions",
heading: "Table: TLS Versions",
columns: [
{ column: "Version", description: "Versi TLS yang digunakan (TLS 1.0, 1.1, 1.2, 1.3)." },
{ column: "Count", description: "Jumlah koneksi yang menggunakan versi ini dalam rentang waktu aktif." },
{ column: "Risk", description: "TLS 1.0/1.1 = Berisiko Tinggi. TLS 1.2 = Sedang. TLS 1.3 = Aman." },
{ column: "Version", description: "TLS version used (TLS 1.0, 1.1, 1.2, 1.3)." },
{ column: "Count", description: "Number of connections using this version within the active time range." },
{ column: "Risk", description: "TLS 1.0/1.1 = High Risk. TLS 1.2 = Medium Risk. TLS 1.3 = Secure." },
],
},
{
heading: "Tabel: TLS Ciphers",
heading: "Table: TLS Ciphers",
columns: [
{ column: "Cipher Suite", description: "Nama algoritma enkripsi yang digunakan pada koneksi TLS." },
{ column: "Strength", description: "Kekuatan enkripsi: Strong, Weak, atau Insecure." },
{ column: "Count", description: "Frekuensi penggunaan cipher ini dalam rentang waktu aktif." },
{ column: "Cipher Suite", description: "Name of the encryption algorithm suite used on the TLS connection." },
{ column: "Strength", description: "Encryption strength: Strong, Weak, or Insecure." },
{ column: "Count", description: "Frequency of this cipher suite's usage within the active time range." },
],
},
],
@@ -367,23 +387,23 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Topology (Infrastructure)",
url: "/network-infrastructure",
icon: "🌐",
summary: "Visualisasi dan rincian topologi fisik, logis, dan geografis dari infrastruktur jaringan.",
summary: "Visualization and details of the physical, logical, and geographical network infrastructure topology.",
sections: [
{
heading: "Tab: Routing & Geography",
content: "Melihat statistik wilayah (Top Regions) dan perkotaan (Top Cities) asal/tujuan pengiriman trafik jaringan.",
content: "View statistics for regional areas (Top Regions) and cities (Top Cities) source/destination of network traffic.",
},
{
heading: "Tab: Interfaces & VLANs",
content: "Daftar antarmuka jaringan fisik (Interfaces) dan penanda VLAN (VLAN Tags) yang aktif mengalirkan data.",
content: "List of physical network interfaces (Interfaces) and VLAN tags (VLAN Tags) actively transmitting data.",
},
{
heading: "Tab: IP & MAC",
content: "Daftar alamat IP remote teraktif, statistik versi protokol IP (IPv4 vs IPv6), dan pemakaian bandwidth berdasarkan MAC Address lokal.",
content: "List of most active remote IP addresses, IP protocol version statistics (IPv4 vs IPv6), and bandwidth usage by local MAC Address.",
},
{
heading: "Tab: Flow Profiles",
content: "Log karakteristik aliran data berdasarkan jenis flow (Flow Types) dan sumber asal flow (Flow Origins) seperti Local, Inbound, Outbound.",
content: "Logs data flow characteristics based on flow type (Flow Types) and flow source (Flow Origins) such as Local, Inbound, Outbound.",
},
],
},
@@ -392,15 +412,15 @@ export const PAGE_GUIDES: PageGuide[] = [
title: "Application Database (Lookup)",
url: "/lookup",
icon: "🔍",
summary: "Katalog referensi global database aplikasi yang dapat dikenali oleh sistem DPI.",
summary: "Global reference catalog database of applications recognizable by the DPI system.",
sections: [
{
heading: "Katalog & Pencarian",
content: "Gunakan bar pencarian di bagian atas untuk menemukan aplikasi tertentu berdasarkan nama, atau gunakan dropdown filter kategori untuk menyaring daftar aplikasi.",
heading: "Catalog & Search",
content: "Use the search bar at the top to find specific applications by name, or use the category dropdown filter to filter the list of applications.",
},
{
heading: "Detail Aplikasi (Modal)",
content: "Klik salah satu card aplikasi untuk membuka modal informasi lengkap yang merinci deskripsi aplikasi, tingkat risiko keamanan (Risk Level), port standar (Default Port), serta protokol transport yang digunakan.",
heading: "Application Detail (Modal)",
content: "Click on an application card to open a detailed information modal detailing the application's description, security risk level (Risk Level), default port (Default Port), and transport protocol used.",
},
],
},
@@ -408,11 +428,11 @@ export const PAGE_GUIDES: PageGuide[] = [
// ── Global UI Conventions ─────────────────────────────────────────────────────
export const UI_CONVENTIONS = [
{ symbol: "● (hijau)", meaning: "Status aktif / Online / Kondisi aman" },
{ symbol: "● (merah)", meaning: "Status tidak aktif / Offline / Berisiko" },
{ symbol: "● (kuning/amber)", meaning: "Perlu perhatian / Warning" },
{ symbol: "Teks terpotong (...)", meaning: "Hover ke teks untuk melihat nilai lengkap" },
{ symbol: "KB / MB / GB", meaning: "Satuan ukuran data (1 MB = 1.000 KB)" },
{ symbol: "Format waktu WIB/WITA/WIT", meaning: "Semua waktu ditampilkan dalam zona waktu Indonesia" },
{ symbol: "Pagination < 1/N >", meaning: "Navigasi halaman — setiap halaman menampilkan 50 baris data" },
{ symbol: "● (green)", meaning: "Active / Online status / Secure condition" },
{ symbol: "● (red)", meaning: "Inactive / Offline status / At risk" },
{ symbol: "● (yellow/amber)", meaning: "Requires attention / Warning" },
{ symbol: "Truncated text (...)", meaning: "Hover over the text to view full value" },
{ symbol: "KB / MB / GB", meaning: "Data size units (1 MB = 1,000 KB)" },
{ symbol: "Time format WIB/WITA/WIT", meaning: "All timestamps displayed in Indonesia local time zone" },
{ symbol: "Pagination < 1/N >", meaning: "Page navigation — each page displays up to 50 rows of data" },
];