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