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

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

No files matched your search

+73 -70
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import { useState, useEffect } from "react";
import { fetchAgentDetails, AgentDetails } from "@/lib/api";
@@ -19,7 +19,7 @@ interface Props {
type Tab = "devices" | "flows" | "apps" | "security" | "events" | "mac";
// ── Severity colour helper ─────────────────────────────────────────────────
// ── Severity colour helper ─────────────────────────────────────────────────
function severityStyle(sev: string | null) {
switch ((sev || "").toLowerCase()) {
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
@@ -32,17 +32,17 @@ function severityStyle(sev: string | null) {
function riskStyle(level: string | null) {
switch ((level || "").toLowerCase()) {
case "rawan":
case "vulnerable":
case "critical": return { badge: "bg-red-500/15 text-red-400 ring-red-500/30", dot: "bg-red-400" };
case "sedang":
case "moderate":
case "medium": return { badge: "bg-yellow-500/15 text-yellow-400 ring-yellow-500/30", dot: "bg-yellow-400" };
case "aman":
case "safe":
case "low": return { badge: "bg-green-500/15 text-green-400 ring-green-500/30", dot: "bg-green-400" };
default: return { badge: "bg-slate-500/15 text-muted-foreground ring-slate-500/30", dot: "bg-slate-400" };
}
}
// ── Shared table wrapper ────────────────────────────────────────────────────
// ── Shared table wrapper ────────────────────────────────────────────────────
function TableWrap({ children, head }: { children: React.ReactNode; head: string[] }) {
return (
<div className="overflow-x-auto rounded-xl border border-border/50">
@@ -64,7 +64,7 @@ function EmptyState({ msg }: { msg: string }) {
return <p className="text-muted-foreground text-sm text-center py-10">{msg}</p>;
}
// ── Main component ──────────────────────────────────────────────────────────
// ── Main component ──────────────────────────────────────────────────────────
export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
const [data, setData] = useState<AgentDetails | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -114,7 +114,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
className={`relative bg-card border border-border rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
onClick={(e) => e.stopPropagation()}
>
{/* ── Header ── */}
{/* ── Header ── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-blue-900/30 to-purple-900/20 rounded-t-2xl flex-shrink-0">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/20 rounded-lg ring-1 ring-blue-500/30">
@@ -130,24 +130,24 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
</button>
</div>
{/* ── Body ── */}
{/* ── Body ── */}
<div className="flex-1 overflow-y-auto min-h-0 custom-scrollbar">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<Loader2 className="w-8 h-8 animate-spin text-blue-400" />
<p className="text-muted-foreground text-sm">Loading agent data…</p>
<p className="text-muted-foreground text-sm">Loading agent data…</p>
</div>
) : error ? (
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
) : data ? (
<>
{/* ── KPI row ── */}
{/* ── KPI row ── */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b border-border/50 flex-shrink-0">
{[
{ label: "Devices", value: data.devices.length, color: "text-card-foreground" },
{ label: "Flows", value: data.summary?.active_flows ?? data.flows.length, color: "text-card-foreground" },
{ label: "Download", value: fmtBytes(data.summary?.bandwidth_down ?? 0), color: "text-cyan-400" },
{ label: "Upload", value: fmtBytes(data.summary?.bandwidth_up ?? 0), color: "text-green-400" },
{ label: "Devices", value: data.summary?.total_devices || data.devices.length, color: "text-card-foreground" },
{ label: "Flows", value: data.summary?.active_flows || data.flows.length, color: "text-card-foreground" },
{ label: "Download", value: fmtBytes(data.summary?.bandwidth_down || data.devices.reduce((acc, d) => acc + (d.download ?? 0), 0)), color: "text-cyan-400" },
{ label: "Upload", value: fmtBytes(data.summary?.bandwidth_up || data.devices.reduce((acc, d) => acc + (d.upload ?? 0), 0)), color: "text-green-400" },
].map((kpi) => (
<div key={kpi.label} className="bg-secondary rounded-xl p-3 border border-border/50">
<p className="text-xs text-muted-foreground mb-1">{kpi.label}</p>
@@ -156,15 +156,15 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
))}
</div>
{/* ── Security alert strip ── */}
{/* ── Security alert strip ── */}
{secCount > 0 && (
<div className="mx-6 mt-3 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-xs text-red-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span><strong>{secCount}</strong> security alerts detected on this agent — check the Security tab</span>
<span><strong>{secCount}</strong> security alerts detected on this agent — check the Security tab</span>
</div>
)}
{/* ── Tabs ── */}
{/* ── Tabs ── */}
<div className="flex gap-1 px-6 py-3 border-b border-border/50 flex-shrink-0 flex-wrap">
{tabs.map((tab) => (
<button
@@ -187,7 +187,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
))}
</div>
{/* ── Tab content ── */}
{/* ── Tab content ── */}
<div className="px-6 py-4 space-y-4">
{/* DEVICES */}
@@ -217,7 +217,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
)}
{d.has_insecure && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ring-1 bg-orange-500/10 ring-orange-500/30 text-orange-400">
⚠ Insecure
âš  Insecure
</span>
)}
</div>
@@ -229,10 +229,10 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
</div>
</div>
<div className="text-right flex-shrink-0 space-y-0.5">
<p className="text-xs text-cyan-400">↓ {fmtBytes(d.download ?? 0)}</p>
<p className="text-xs text-green-400">↑ {fmtBytes(d.upload ?? 0)}</p>
<p className="text-xs text-cyan-400">↓ {fmtBytes(d.download ?? 0)}</p>
<p className="text-xs text-green-400">↑ {fmtBytes(d.upload ?? 0)}</p>
{d.encrypted_pct !== null && (
<p className="text-xs text-muted-foreground">🔒 {d.encrypted_pct}%</p>
<p className="text-xs text-muted-foreground">🔒 {d.encrypted_pct}%</p>
)}
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 ml-auto transition-colors" />
</div>
@@ -247,22 +247,22 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
{/* FLOWS */}
{activeTab === "flows" && (
data.flows.length === 0 ? <EmptyState msg="No flows found for this agent." /> : (
<TableWrap head={["Source IP", "Destination", "App / Domain", "Proto", "↓", "↑", "Last Seen"]}>
<TableWrap head={["Source IP", "Destination", "App / Domain", "Proto", "↓", "↑", "Last Seen"]}>
{data.flows.map((f, i) => (
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
<td className="px-3 py-2 font-mono text-xs text-blue-400 whitespace-nowrap">{f.src_ip || "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-blue-400 whitespace-nowrap">{f.src_ip || "—"}</td>
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-slate-300">
{f.dst_ip || "—"}{f.dst_port ? <span className="text-muted-foreground">:{f.dst_port}</span> : ""}
{f.dst_ip || "—"}{f.dst_port ? <span className="text-muted-foreground">:{f.dst_port}</span> : ""}
</span>
{f.dst_ip && <IpDetails ip={f.dst_ip} />}
</div>
</td>
<td className="px-3 py-2 whitespace-nowrap max-w-[200px]">
<div className="flex flex-col gap-0.5">
<span className="font-semibold text-purple-300 text-xs truncate" title={f.app_label || "—"}>
{f.app_label || "—"}
<span className="font-semibold text-purple-300 text-xs truncate" title={f.app_label || "—"}>
{f.app_label || "—"}
</span>
{f.domain && (
<span className="text-[10px] text-muted-foreground italic truncate" title={f.domain}>
@@ -271,10 +271,10 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
)}
</div>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{f.protocol || "—"}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{f.protocol || "—"}</td>
<td className="px-3 py-2 text-right text-xs text-cyan-400 whitespace-nowrap">{fmtBytes(f.download)}</td>
<td className="px-3 py-2 text-right text-xs text-green-400 whitespace-nowrap">{fmtBytes(f.upload)}</td>
<td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
<td className="px-3 py-2 text-xs text-muted-foreground whitespace-nowrap">{f.last_seen?.slice(0, 16) || "—"}</td>
</tr>
))}
</TableWrap>
@@ -301,8 +301,8 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
<span className="text-xs font-mono text-card-foreground">{fmtBytes(total)}</span>
</div>
<div className="flex gap-3 text-xs text-muted-foreground mb-2">
<span className="text-cyan-400">↓ {fmtBytes(a.download ?? 0)}</span>
<span className="text-green-400">↑ {fmtBytes(a.upload ?? 0)}</span>
<span className="text-cyan-400">↓ {fmtBytes(a.download ?? 0)}</span>
<span className="text-green-400">↑ {fmtBytes(a.upload ?? 0)}</span>
</div>
<div className="h-1 bg-secondary rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all" style={{ width: `${pct}%` }} />
@@ -329,8 +329,8 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
const rc = riskStyle(r.risk_level);
return (
<tr key={i} className="hover:bg-white/[0.03]">
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.device_label || "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.device_label || "—"}</td>
<td className="px-3 py-2 text-xs">
<div className="flex items-center gap-2">
<div className="w-16 h-1.5 bg-white/10 rounded-full overflow-hidden">
@@ -342,7 +342,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${rc.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${rc.dot}`} />
{r.risk_level || "—"}
{r.risk_level || "—"}
</span>
</td>
<td className="px-3 py-2 text-xs text-cyan-400">{fmtBytes(r.encrypted ?? 0)}</td>
@@ -361,24 +361,24 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
<span className="ml-auto text-xs text-muted-foreground">{data.security?.insecure_protocols.length ?? 0} records</span>
</h4>
{(data.security?.insecure_protocols.length ?? 0) === 0 ? <EmptyState msg="No insecure protocols detected for this agent." /> : (
<TableWrap head={["IP", "Protocol", "Risk", "App", "Dst IP:Port", "↓", "↑"]}>
<TableWrap head={["IP", "Protocol", "Risk", "App", "Dst IP:Port", "↓", "↑"]}>
{data.security!.insecure_protocols.map((r, i) => {
const sc = severityStyle(r.risk);
return (
<tr key={i} className="hover:bg-white/[0.03]">
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
<td className="px-3 py-2 text-xs text-slate-200">{r.protocol || "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
<td className="px-3 py-2 text-xs text-slate-200">{r.protocol || "—"}</td>
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${sc.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
{r.risk || "—"}
{r.risk || "—"}
</span>
</td>
<td className="px-3 py-2 text-xs text-purple-300 truncate max-w-[100px]">{r.app_label || "—"}</td>
<td className="px-3 py-2 text-xs text-purple-300 truncate max-w-[100px]">{r.app_label || "—"}</td>
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-muted-foreground">
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
</span>
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
</div>
@@ -404,21 +404,21 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
const sc = severityStyle(r.severity);
return (
<tr key={i} className="hover:bg-white/[0.03]">
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.ip_address || "—"}</td>
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-muted-foreground">
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
</span>
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
</div>
</td>
<td className="px-3 py-2 text-xs text-slate-300">{r.protocol || "—"}</td>
<td className="px-3 py-2 text-xs text-yellow-300 font-mono">{r.username || "—"}</td>
<td className="px-3 py-2 text-xs text-slate-300">{r.protocol || "—"}</td>
<td className="px-3 py-2 text-xs text-yellow-300 font-mono">{r.username || "—"}</td>
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 ${sc.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
{r.severity || "—"}
{r.severity || "—"}
</span>
</td>
</tr>
@@ -438,15 +438,15 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
<TableWrap head={["Remote IP", "Local IP", "Reputation", "Score", "Country", "Blacklisted"]}>
{data.security!.ip_reputation.map((r, i) => (
<tr key={i} className="hover:bg-white/[0.03]">
<td className="px-3 py-2 font-mono text-xs text-red-400">{r.ip_address || "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.local_ip || "—"}</td>
<td className="px-3 py-2 text-xs text-slate-300">{r.reputation || "—"}</td>
<td className="px-3 py-2 text-xs text-yellow-400">{r.score ?? "—"}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{r.country || "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-red-400">{r.ip_address || "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-blue-400">{r.local_ip || "—"}</td>
<td className="px-3 py-2 text-xs text-slate-300">{r.reputation || "—"}</td>
<td className="px-3 py-2 text-xs text-yellow-400">{r.score ?? "—"}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{r.country || "—"}</td>
<td className="px-3 py-2 text-xs">
{r.blacklisted
? <span className="text-red-400 font-semibold">⛔ Yes</span>
: <span className="text-green-400">✓ No</span>
? <span className="text-red-400 font-semibold">â›” Yes</span>
: <span className="text-green-400">✓ No</span>
}
</td>
</tr>
@@ -468,10 +468,10 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
: <div className="space-y-2">{data.security!.tor_detections.map((r, i) => (
<div key={i} className="p-2 bg-red-500/5 border border-red-500/20 rounded-lg text-xs">
<div className="flex justify-between">
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
<span className="text-muted-foreground">{r.country || "—"}</span>
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
<span className="text-muted-foreground">{r.country || "—"}</span>
</div>
<div className="text-muted-foreground mt-0.5">Exit: {r.exit_node || "—"}</div>
<div className="text-muted-foreground mt-0.5">Exit: {r.exit_node || "—"}</div>
</div>
))}</div>
}
@@ -488,11 +488,11 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
: <div className="space-y-2">{data.security!.vpn_detections.map((r, i) => (
<div key={i} className="p-2 bg-blue-500/5 border border-blue-500/20 rounded-lg text-xs">
<div className="flex justify-between">
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
<span className="text-purple-400">{r.vpn_type || "—"}</span>
<span className="font-mono text-blue-400">{r.ip_address || "—"}</span>
<span className="text-purple-400">{r.vpn_type || "—"}</span>
</div>
<div className="text-muted-foreground mt-0.5 flex flex-col gap-0.5">
<span>Remote: {r.remote_ip || "—"} • {r.country || "—"}</span>
<span>Remote: {r.remote_ip || "—"} • {r.country || "—"}</span>
{r.remote_ip && <IpDetails ip={r.remote_ip} />}
</div>
{r.confidence && <div className="text-muted-foreground">Confidence: {(r.confidence * 100).toFixed(0)}%</div>}
@@ -514,15 +514,15 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
<tr key={i} className="hover:bg-white/[0.03]">
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex flex-col gap-0.5">
<span className="font-mono text-xs text-blue-400">{r.ip_address || "—"}</span>
<span className="font-mono text-xs text-blue-400">{r.ip_address || "—"}</span>
{r.ip_address && <IpDetails ip={r.ip_address} />}
</div>
</td>
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.hostname || "—"}</td>
<td className="px-3 py-2 text-xs text-cyan-400">{r.server_type || "—"}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{r.port ?? "—"}</td>
<td className="px-3 py-2 text-xs text-purple-300">{r.protocol || "—"}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{r.os_label || "—"}</td>
<td className="px-3 py-2 text-xs text-slate-300 truncate max-w-[120px]">{r.hostname || "—"}</td>
<td className="px-3 py-2 text-xs text-cyan-400">{r.server_type || "—"}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{r.port ?? "—"}</td>
<td className="px-3 py-2 text-xs text-purple-300">{r.protocol || "—"}</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{r.os_label || "—"}</td>
</tr>
))}
</TableWrap>
@@ -546,17 +546,17 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
<div className="flex items-start gap-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ring-1 flex-shrink-0 ${sc.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
{ev.severity || "—"}
{ev.severity || "—"}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<span className="font-semibold text-card-foreground">{ev.event_type || "Unknown"}</span>
<span className="font-mono text-blue-400">{ev.ip_address || "—"}</span>
<span className="font-mono text-blue-400">{ev.ip_address || "—"}</span>
{ev.mac_address && <span className="font-mono text-muted-foreground">{ev.mac_address}</span>}
</div>
{ev.description && <p className="text-muted-foreground leading-relaxed">{ev.description}</p>}
</div>
<span className="text-slate-600 flex-shrink-0 whitespace-nowrap">{ev.event_at?.slice(0, 16) || "—"}</span>
<span className="text-slate-600 flex-shrink-0 whitespace-nowrap">{ev.event_at?.slice(0, 16) || "—"}</span>
</div>
</div>
);
@@ -586,8 +586,8 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
</div>
</div>
<div className="flex gap-4 text-xs mb-2">
<span className="text-cyan-400">↓ {fmtBytes(m.download ?? 0)}</span>
<span className="text-green-400">↑ {fmtBytes(m.upload ?? 0)}</span>
<span className="text-cyan-400">↓ {fmtBytes(m.download ?? 0)}</span>
<span className="text-green-400">↑ {fmtBytes(m.upload ?? 0)}</span>
</div>
<div className="h-1 bg-secondary rounded-full overflow-hidden">
<div className="h-full bg-gradient-to-r from-cyan-500 to-blue-500 rounded-full" style={{ width: `${pct}%` }} />
@@ -616,3 +616,6 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
</>
);
}
+37 -98
View File
@@ -1,7 +1,8 @@
"use client";
"use client";
import { useState, useEffect } from "react";
import { fetchAppDetails, AppDetails } from "@/lib/api";
import { useTimeFilter } from "@/contexts/TimeFilterContext";
import { fmtBytes } from "@/lib/utils";
import { Loader2, X, Globe, Server, Monitor } from "lucide-react";
@@ -17,6 +18,7 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isClosing, setIsClosing] = useState(false);
const { timeRange } = useTimeFilter();
const handleClose = () => {
setIsClosing(true);
@@ -28,11 +30,11 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
useEffect(() => {
setIsLoading(true);
setError(null);
fetchAppDetails(appLabel)
fetchAppDetails(appLabel, timeRange)
.then(setData)
.catch((e) => setError(e.message))
.finally(() => setIsLoading(false));
}, [appLabel]);
}, [appLabel, timeRange]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
@@ -88,112 +90,47 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
</div>
</div>
{/* Per-Agent Breakdown */}
{/* Devices accessing this app */}
<div>
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
<Server className="w-4 h-4 text-blue-400" />
Usage per Agent
<Monitor className="w-4 h-4 text-blue-400" />
Devices Accessing {appLabel}
</h4>
{data.agent_scorecard.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">No traffic from known agents for this app yet.</p>
{(!data.devices || data.devices.length === 0) ? (
<p className="text-muted-foreground text-sm text-center py-4">No active devices found for this app in the selected time range.</p>
) : (
<div className="space-y-2">
{data.agent_scorecard.map((a, i) => {
const maxDl = Math.max(...data.agent_scorecard.map(x => x.download), 1);
const pct = (a.download / maxDl) * 100;
return (
<div key={i} className="p-3 bg-white/[0.03] border border-border/50 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-400" />
<span className="text-sm font-medium text-card-foreground">{a.agent_label}</span>
{a.agent_uuid && (
<span className="text-xs font-mono text-muted-foreground">{a.agent_uuid}</span>
)}
</div>
<div className="flex items-center gap-3 text-xs font-mono">
<span className="text-cyan-400">↓ {fmtBytes(a.download)}</span>
<span className="text-green-400">↑ {fmtBytes(a.upload ?? 0)}</span>
</div>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 rounded-full transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Top IPs */}
<div>
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
<Monitor className="w-4 h-4 text-cyan-400" />
Top 5 Flow Records (Realtime)
{data.top_ips.filter(ip => ip.blacklisted).length > 0 && (
<span className="ml-auto text-xs text-red-400 bg-red-500/10 px-2 py-0.5 rounded-full ring-1 ring-red-500/30">
🚫 {data.top_ips.filter(ip => ip.blacklisted).length} Blacklisted
</span>
)}
</h4>
{data.top_ips.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-4">No device data available.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left border-b border-white/10">
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground w-8">#</th>
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Source IP</th>
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Dest IP</th>
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Label / Domain</th>
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground">Time</th>
<th className="pb-3 pr-4 text-xs font-medium text-muted-foreground text-right">Download</th>
<th className="pb-3 text-xs font-medium text-muted-foreground text-right">Upload</th>
<div className="overflow-x-auto rounded-lg border border-border/50 bg-white/[0.02]">
<table className="w-full text-sm text-left whitespace-nowrap">
<thead className="bg-secondary/40 text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium">Timestamp</th>
<th className="px-4 py-3 font-medium">Agent</th>
<th className="px-4 py-3 font-medium">IP Address</th>
<th className="px-4 py-3 font-medium">Hostname</th>
<th className="px-4 py-3 font-medium">OS</th>
<th className="px-4 py-3 font-medium">MAC Address</th>
<th className="px-4 py-3 font-medium">Download</th>
<th className="px-4 py-3 font-medium">Upload</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{data.top_ips.map((ip, i) => {
const maxDl = Math.max(...data.top_ips.map(x => x.download), 1);
const pct = (ip.download / maxDl) * 100;
return (
<tr key={i} className="hover:bg-white/[0.04] transition-colors group">
<td className="py-3 pr-4 text-xs text-muted-foreground group-hover:text-white transition-colors">{i + 1}</td>
<td className="py-3 pr-4 font-mono text-xs">
<div className="flex items-center gap-1.5 flex-wrap">
<span className={ip.blacklisted ? "text-red-400" : "text-blue-400"}>{ip.ip_address || "-"}</span>
{ip.blacklisted && (
<span className="text-xs text-red-300 bg-red-500/10 px-1.5 py-0.5 rounded-full ring-1 ring-red-500/30">🚫</span>
)}
{!ip.blacklisted && ip.reputation && ip.reputation !== "Unknown" && (
<span className="text-xs text-muted-foreground bg-secondary px-1.5 py-0.5 rounded">{ip.reputation}</span>
)}
</div>
</td>
<td className="py-3 pr-4 font-mono text-xs text-muted-foreground truncate max-w-[120px]">
{ip.dst_ip || "-"}
</td>
<td className="py-3 pr-4 text-xs text-slate-300 truncate max-w-[150px]">
{ip.domain || data.app_label || "-"}
</td>
<td className="py-3 pr-4 text-xs text-muted-foreground whitespace-nowrap">
{ip.last_seen ? new Date(ip.last_seen).toLocaleString('id-ID', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : "-"}
</td>
<td className="py-3 pr-4 text-right text-xs font-medium text-cyan-400">{fmtBytes(ip.download)}</td>
<td className="py-3 text-right text-xs font-medium text-green-400">{fmtBytes(ip.upload ?? 0)}</td>
</tr>
);
})}
<tbody className="divide-y divide-border/50">
{data.devices.map((dev: any, i: number) => (
<tr key={i} className="hover:bg-white/[0.02] transition-colors">
<td className="px-4 py-3 text-muted-foreground">{new Date(dev.timestamp).toLocaleString()}</td>
<td className="px-4 py-3">{dev.agent_uuid || 'Global'}</td>
<td className="px-4 py-3 font-mono text-blue-300">{dev.ip || '-'}</td>
<td className="px-4 py-3">{dev.hostname || '-'}</td>
<td className="px-4 py-3">{dev.os || '-'}</td>
<td className="px-4 py-3 font-mono text-muted-foreground">{dev.mac || '-'}</td>
<td className="px-4 py-3 text-cyan-400">{fmtBytes(dev.download)}</td>
<td className="px-4 py-3 text-green-400">{fmtBytes(dev.upload)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
) : null}
</div>
@@ -201,3 +138,5 @@ export function AppDetailModal({ appLabel, favicon, category, onClose }: Props)
</div>
);
}
+5 -5
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { Search } from "lucide-react";
export interface Column<T> {
header: string;
header: React.ReactNode;
accessor: (row: T, index: number) => React.ReactNode;
className?: string;
}
@@ -58,11 +58,11 @@ export function DataTable<T>({
)}
<div className="rounded-xl border border-border overflow-hidden bg-card/50 backdrop-blur-xl shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<table className="w-full text-sm text-left table-fixed">
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50">
<tr>
{columns.map((col, i) => (
<th key={i} className={`px-4 py-3.5 font-medium tracking-wide ${col.className || ""}`}>
<th key={i} className={`px-2 py-3.5 font-medium tracking-wide text-center ${col.className || ""}`}>
{col.header}
</th>
))}
@@ -98,10 +98,10 @@ export function DataTable<T>({
paginatedData.map((row, i) => (
<tr
key={i}
className={`hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
className={`h-[64px] hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
>
{columns.map((col, j) => (
<td key={j} className={`px-4 py-3 text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
<td key={j} className={`px-2 py-3 text-center text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
{col.accessor(row, i)}
</td>
))}
File diff suppressed because it is too large. Load diff
+98 -75
View File
@@ -48,11 +48,15 @@ const ORIGIN_LNG = 106.8456;
interface GlobeMapProps {
data: {
countryCode: string;
startLat: number;
startLng: number;
endLat: number;
endLng: number;
fromLabel: string;
toLabel: string;
value: number;
upload?: number;
download?: number;
label: string;
download: number;
upload: number;
}[];
}
@@ -109,12 +113,11 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
controls.minDistance = 140; // Zoom in limit
controls.maxDistance = 350; // Zoom out limit
// Listen for interactions to pause auto-rotate smoothly
controls.addEventListener('start', () => setUserInteracting(true));
controls.addEventListener('end', () => setUserInteracting(false));
// Focus on Southeast Asia initially
globeRef.current.pointOfView({ lat: ORIGIN_LAT, lng: ORIGIN_LNG, altitude: 1.8 }, 2000);
// Focus on Southeast Asia initially (or dynamically from first data point if available)
globeRef.current.pointOfView({ lat: -6.2, lng: 106.8, altitude: 1.8 }, 2000);
}
}, 100);
@@ -139,12 +142,11 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
// Smooth Material Color Interpolation for Theme Switching
useEffect(() => {
// Target ocean colors
const targetColor = new THREE.Color(isLight ? '#e0f2fe' : '#020617');
let frameId: number;
const lerpColor = () => {
customGlobeMaterial.color.lerp(targetColor, 0.08); // Smooth interpolation step
customGlobeMaterial.color.lerp(targetColor, 0.08);
const rDiff = Math.abs(customGlobeMaterial.color.r - targetColor.r);
const gDiff = Math.abs(customGlobeMaterial.color.g - targetColor.g);
@@ -163,47 +165,50 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
// Prepare Data Memos
const arcsData = useMemo(() => {
return data
.filter(d => d.countryCode !== "ID" && COUNTRY_COORDS[d.countryCode])
.map(d => {
const [lng, lat] = COUNTRY_COORDS[d.countryCode];
return {
startLat: ORIGIN_LAT,
startLng: ORIGIN_LNG,
endLat: lat,
endLng: lng,
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
name: d.label,
value: d.value,
upload: d.upload || 0,
download: d.download || 0
};
});
return data.map(d => ({
...d,
color: isLight ? ['#38bdf8', '#0284c7'] : ['#0ea5e9', '#38bdf8'],
}));
}, [data, isLight]);
const pointsData = useMemo(() => {
const points = arcsData.map(arc => ({
lat: arc.endLat,
lng: arc.endLng,
size: 0.1,
color: isLight ? '#0284c7' : '#38bdf8',
name: arc.name,
value: arc.value,
upload: arc.upload,
download: arc.download,
isOrigin: false
}));
points.push({
lat: ORIGIN_LAT,
lng: ORIGIN_LNG,
size: 0.2,
color: '#f43f5e',
name: 'Indonesia (Origin)',
value: 0,
upload: 0,
download: 0,
isOrigin: true
const points = [];
const originSet = new Set();
const destSet = new Set();
arcsData.forEach(arc => {
const originKey = `${arc.startLat},${arc.startLng}`;
const destKey = `${arc.endLat},${arc.endLng}`;
if (!destSet.has(destKey)) {
points.push({
lat: arc.endLat,
lng: arc.endLng,
size: 0.1,
color: isLight ? '#0284c7' : '#38bdf8',
name: arc.toLabel,
value: arc.value,
upload: arc.upload,
download: arc.download,
isOrigin: false
});
destSet.add(destKey);
}
if (!originSet.has(originKey)) {
points.push({
lat: arc.startLat,
lng: arc.startLng,
size: 0.2,
color: '#f43f5e',
name: arc.fromLabel,
value: 0,
upload: 0,
download: 0,
isOrigin: true
});
originSet.add(originKey);
}
});
return points;
@@ -211,22 +216,36 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
// Extract All Labels + Origin to display statically
const labelsData = useMemo(() => {
const staticLabels = arcsData.map(arc => ({
lat: arc.endLat,
lng: arc.endLng,
name: arc.name,
color: isLight ? '#0f172a' : '#f8fafc'
}));
// Add origin
staticLabels.push({
lat: ORIGIN_LAT,
lng: ORIGIN_LNG,
name: 'Indonesia',
color: isLight ? '#e11d48' : '#fda4af'
const labels: any[] = [];
const originSet = new Set();
const destSet = new Set();
arcsData.forEach(arc => {
const originKey = `${arc.startLat},${arc.startLng}`;
const destKey = `${arc.endLat},${arc.endLng}`;
if (!destSet.has(destKey)) {
labels.push({
lat: arc.endLat,
lng: arc.endLng,
name: arc.toLabel,
color: isLight ? '#0f172a' : '#f8fafc'
});
destSet.add(destKey);
}
if (!originSet.has(originKey)) {
labels.push({
lat: arc.startLat,
lng: arc.startLng,
name: arc.fromLabel,
color: isLight ? '#0f172a' : '#f8fafc'
});
originSet.add(originKey);
}
});
return staticLabels;
return labels;
}, [arcsData, isLight]);
return (
@@ -247,14 +266,12 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
atmosphereColor={isLight ? "#38bdf8" : "#1e40af"}
atmosphereAltitude={isLight ? 0.15 : 0.25}
// Polygons Configuration
polygonsData={countries ? countries.features : []}
polygonAltitude={0.015}
polygonCapColor={() => isLight ? '#4ade80' : '#0f172a'}
polygonSideColor={() => isLight ? '#22c55e' : '#020617'}
polygonStrokeColor={() => isLight ? '#ffffff' : '#0ea5e9'}
// Arcs configuration
arcsData={arcsData}
arcStartLat={(d: any) => d.startLat}
arcStartLng={(d: any) => d.startLng}
@@ -264,11 +281,21 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
arcDashLength={0.4}
arcDashGap={0.2}
arcDashAnimateTime={2000}
arcAltitude={0.3}
arcAltitude={(d: any) => {
const latDiff = Math.abs(d.startLat - d.endLat);
const lngDiff = Math.abs(d.startLng - d.endLng);
const dist = Math.sqrt(latDiff*latDiff + lngDiff*lngDiff);
return Math.max(0.1, dist * 0.005);
}}
arcStroke={isLight ? 0.8 : 0.6}
arcLabel={(d: any) => `
<div style="background: rgba(0,0,0,0.85); color: white; padding: 10px 14px; border-radius: 8px; font-size: 13px; font-weight: 500; min-width: 180px; border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px;">Flow to ${d.name}</div>
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #facc15;">
${d.fromLabel}
</div>
<div style="font-weight: 600; font-size: 14px; border-bottom: 1px solid rgba(255,255,255,0.2); padding-bottom: 6px; margin-bottom: 6px; color: #38bdf8;">
${d.toLabel}
</div>
<div style="display: flex; justify-content: space-between; margin-bottom: 4px;">
<span style="color: #9ca3af;">Total Traffic:</span> <span>${fmtBytes(d.value)}</span>
</div>
@@ -281,7 +308,6 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
</div>
`}
// Points configuration
pointsData={pointsData}
pointLat="lat"
pointLng="lng"
@@ -306,7 +332,6 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
</div>
`}
// Rings configuration (Ping effect for points)
ringsData={pointsData}
ringLat="lat"
ringLng="lng"
@@ -315,16 +340,14 @@ export const GlobeMap = ({ data }: GlobeMapProps) => {
ringPropagationSpeed={3}
ringRepeatPeriod={1000}
// Labels Configuration (All Destinations + Origin)
labelsData={labelsData}
labelLat="lat"
labelLng="lng"
labelLat={(d: any) => d.lat}
labelLng={(d: any) => d.lng}
labelDotRadius={0}
labelSize={1.5}
labelText="name"
labelSize={1.4}
labelDotRadius={0.2}
labelColor="color"
labelResolution={5}
labelAltitude={0.03}
labelColor={(d: any) => d.color}
labelResolution={2}
/>
</div>
);