Update logo to PNG, restructure network intelligence layout, add globe and fix pagination
This commit is contained in:
1 parent
93d4002b27
commit
03c05a41ec
15 files changed
+956
-155
No files matched your search
@@ -10,6 +10,7 @@ import { WorldMap } from "@/components/ui/WorldMap";
|
||||
|
||||
export default function GeographyPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const { data, isLoading } = useCountries(50); // fetch 50 countries
|
||||
|
||||
useEffect(() => {
|
||||
@@ -74,15 +75,36 @@ export default function GeographyPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
<CardContent className="p-1 sm:p-4">
|
||||
<WorldMap data={mapData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{showMap && (
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-lg text-white">Heatmap Visualization</CardTitle>
|
||||
<button
|
||||
onClick={() => setShowMap(false)}
|
||||
className="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/50 rounded-lg transition-colors flex items-center gap-2 text-sm"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
Close Heatmap
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-1 sm:p-4 pt-0">
|
||||
<WorldMap data={mapData} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-lg text-white">Traffic by Country ({data.length})</CardTitle>
|
||||
{!showMap && (
|
||||
<button
|
||||
onClick={() => setShowMap(true)}
|
||||
className="px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 rounded-lg transition-all shadow-[0_0_15px_rgba(59,130,246,0.15)] flex items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
||||
Load Geographic Heatmap
|
||||
</button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
|
||||
@@ -51,68 +51,80 @@ export default function NetworkIntelligencePage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* App Categories */}
|
||||
{/* Left Column: App Categories List */}
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-lg text-white">App Categories</CardTitle>
|
||||
<CardTitle className="text-lg text-white">App Categories List</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={appCategories.data || []}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="download"
|
||||
nameKey="category_label"
|
||||
>
|
||||
{(appCategories.data || []).map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
|
||||
formatter={(val: any) => fmtBytes(val as number)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<CardContent>
|
||||
<DataTable data={appCategories.data || []} columns={appCols} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Continents */}
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-lg text-white">Continents (Download Volume)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={continents.data || []} layout="vertical" margin={{ top: 0, right: 30, left: 40, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" horizontal={true} vertical={false} />
|
||||
<XAxis type="number" stroke="#8b949e" tickFormatter={(v) => `${(v/1000000000).toFixed(1)}GB`} />
|
||||
<YAxis dataKey="continent_name" type="category" stroke="#8b949e" fontSize={12} />
|
||||
<RechartsTooltip
|
||||
cursor={{fill: '#21262d'}}
|
||||
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
|
||||
formatter={(val: any) => fmtBytes(val as number)}
|
||||
/>
|
||||
<Bar dataKey="download" fill="#58a6ff" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-4 border border-border rounded-md p-4 bg-secondary/30">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Most of your traffic is bound for {continents.data && continents.data.length > 0 ? continents.data[0].continent_name : "unknown destinations"}. Ensure your firewall rules are optimized for latency across trans-oceanic cables.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Right Column: Graphs */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* App Categories Graph */}
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-lg text-white">App Categories Chart</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={appCategories.data || []}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="download"
|
||||
nameKey="category_label"
|
||||
>
|
||||
{(appCategories.data || []).map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
|
||||
formatter={(val: any) => fmtBytes(val as number)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Continents Graph */}
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-lg text-white">Continents (Download Volume)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={continents.data || []} layout="vertical" margin={{ top: 0, right: 30, left: 40, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" horizontal={true} vertical={false} />
|
||||
<XAxis type="number" stroke="#8b949e" tickFormatter={(v) => `${(v/1000000000).toFixed(1)}GB`} />
|
||||
<YAxis dataKey="continent_name" type="category" stroke="#8b949e" fontSize={12} />
|
||||
<RechartsTooltip
|
||||
cursor={{fill: '#21262d'}}
|
||||
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
|
||||
formatter={(val: any) => fmtBytes(val as number)}
|
||||
/>
|
||||
<Bar dataKey="download" fill="#58a6ff" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-4 border border-border rounded-md p-4 bg-secondary/30">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Most of your traffic is bound for {continents.data && continents.data.length > 0 ? continents.data[0].continent_name : "unknown destinations"}. Ensure your firewall rules are optimized for latency across trans-oceanic cables.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
Pie,
|
||||
Cell
|
||||
} from "recharts";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols } from "@/lib/api";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols, useCountries } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { GlobeMap } from "@/components/ui/GlobeMap";
|
||||
import Link from "next/link";
|
||||
|
||||
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
|
||||
@@ -22,6 +23,16 @@ export default function SummaryPage() {
|
||||
const timeline = useTimeline(24);
|
||||
const topApps = useTopApps(7);
|
||||
const topProtocols = useTopProtocols();
|
||||
const countries = useCountries(20);
|
||||
|
||||
const mapData = useMemo(() => {
|
||||
if (!countries.data) return [];
|
||||
return countries.data.map((row) => ({
|
||||
countryCode: row.country_code,
|
||||
value: row.download + row.upload,
|
||||
label: row.country_name,
|
||||
}));
|
||||
}, [countries.data]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -150,80 +161,10 @@ export default function SummaryPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||
{/* Timeline Chart */}
|
||||
<Card className="col-span-4 bg-card/80 backdrop-blur-xl border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white">Bandwidth Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<div className="h-[350px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={timeline.data} margin={{ top: 15, right: 20, left: 10, bottom: 5 }}>
|
||||
<defs>
|
||||
<linearGradient id="colorDownload" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
<linearGradient id="colorUpload" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0}/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="fetched_at"
|
||||
stroke="#a1a1aa"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={10}
|
||||
tickFormatter={(val) => {
|
||||
try {
|
||||
return new Date(val).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (e) {
|
||||
return val;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#a1a1aa"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(val) => {
|
||||
if (val === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(val) / Math.log(k));
|
||||
return parseFloat((val / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(24, 24, 27, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: '#fafafa',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.3)'
|
||||
}}
|
||||
itemStyle={{ color: '#fafafa', fontSize: '13px', fontWeight: 500 }}
|
||||
labelFormatter={(label) => {
|
||||
try {
|
||||
return new Date(label).toLocaleString();
|
||||
} catch (e) {
|
||||
return label;
|
||||
}
|
||||
}}
|
||||
formatter={(value: any, name: any) => [fmtBytes(value as number), name === 'total_download' ? 'Download' : 'Upload']}
|
||||
/>
|
||||
<Area type="monotone" dataKey="total_download" stroke="#3b82f6" strokeWidth={3} fillOpacity={1} fill="url(#colorDownload)" activeDot={{ r: 6, fill: "#3b82f6", stroke: "#18181b", strokeWidth: 2 }} />
|
||||
<Area type="monotone" dataKey="total_upload" stroke="#10b981" strokeWidth={3} fillOpacity={1} fill="url(#colorUpload)" activeDot={{ r: 6, fill: "#10b981", stroke: "#18181b", strokeWidth: 2 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Global Connections Map (Replaces Timeline) */}
|
||||
<div className="col-span-4 relative flex items-center justify-center">
|
||||
<GlobeMap data={mapData} />
|
||||
</div>
|
||||
|
||||
{/* Top Apps & Protocols */}
|
||||
<div className="col-span-3 space-y-6">
|
||||
@@ -315,6 +256,8 @@ export default function SummaryPage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
+1
-1
@@ -7,7 +7,7 @@ const inter = Inter({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "BackOne Dashboard",
|
||||
title: "BackOne - Deep Package Inspection",
|
||||
description: "BackOne by PT. Data Bisnis Solusi - Network Intelligence Dashboard",
|
||||
};
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function LoginPage() {
|
||||
|
||||
<div className="z-10 w-full max-w-md p-8 bg-sidebar/80 backdrop-blur-2xl rounded-2xl border border-border/50 shadow-2xl relative">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<img src="/backone-logo.svg" alt="BackOne" className="w-16 h-16 drop-shadow-[0_0_15px_rgba(220,38,38,0.5)] mb-4" />
|
||||
<img src="/backone-logo.png" alt="BackOne" className="w-16 h-16 drop-shadow-[0_0_15px_rgba(220,38,38,0.5)] mb-4" />
|
||||
<h1 className="text-2xl font-bold text-white tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||
BackOne Dashboard
|
||||
</h1>
|
||||
|
||||
@@ -128,10 +128,15 @@ export function Sidebar() {
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-primary/5 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
<div className="flex h-[72px] shrink-0 items-center px-6 border-b border-border/50 relative z-10">
|
||||
<img src="/backone-logo.svg" alt="BackOne Logo" className="w-8 h-8 mr-3 rounded-full drop-shadow-[0_0_10px_rgba(255,0,0,0.3)]" />
|
||||
<span className="text-lg font-bold text-white tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||
BackOne
|
||||
</span>
|
||||
<img src="/backone-logo.png" alt="BackOne Logo" className="w-8 h-8 mr-3 rounded-full drop-shadow-[0_0_10px_rgba(255,0,0,0.3)]" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-bold text-white tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70 leading-tight">
|
||||
BackOne
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-muted-foreground tracking-wide mt-0.5">
|
||||
Deep Package Inspection
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto relative z-10">
|
||||
<nav className="flex-1 space-y-1.5 px-3 py-6">
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { fmtBytes } from '@/lib/utils';
|
||||
|
||||
// Dynamically import Globe to avoid SSR issues
|
||||
const Globe = dynamic(() => import('react-globe.gl'), { ssr: false });
|
||||
|
||||
const COUNTRY_COORDS: Record<string, [number, number]> = {
|
||||
"ID": [113.9213, -0.7893],
|
||||
"US": [-95.7129, 37.0902],
|
||||
"HK": [114.1095, 22.3964],
|
||||
"SG": [103.8198, 1.3521],
|
||||
"JP": [138.2529, 36.2048],
|
||||
"SC": [55.4920, -4.6796],
|
||||
"GB": [-3.4360, 55.3781],
|
||||
"IT": [12.5674, 41.8719],
|
||||
"CN": [104.1954, 35.8617],
|
||||
"KR": [127.7669, 35.9078],
|
||||
"CA": [-106.3468, 56.1304],
|
||||
"LU": [6.1296, 49.8153],
|
||||
"NL": [5.2913, 52.1326],
|
||||
"TR": [35.2433, 38.9637],
|
||||
"UA": [31.1656, 48.3794]
|
||||
};
|
||||
|
||||
// Jakarta, Indonesia as Origin
|
||||
const ORIGIN_LAT = -6.2088;
|
||||
const ORIGIN_LNG = 106.8456;
|
||||
|
||||
interface GlobeMapProps {
|
||||
data: {
|
||||
countryCode: string;
|
||||
value: number;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const GlobeMap = ({ data }: GlobeMapProps) => {
|
||||
const globeRef = useRef<any>(null);
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 500 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Handle resizing
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
setDimensions({
|
||||
width: containerRef.current.offsetWidth,
|
||||
height: containerRef.current.offsetHeight || 500
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateDimensions();
|
||||
window.addEventListener('resize', updateDimensions);
|
||||
return () => window.removeEventListener('resize', updateDimensions);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-rotate configuration
|
||||
if (globeRef.current) {
|
||||
globeRef.current.controls().autoRotate = true;
|
||||
globeRef.current.controls().autoRotateSpeed = 1.0;
|
||||
|
||||
// Focus on Southeast Asia initially
|
||||
globeRef.current.pointOfView({ lat: ORIGIN_LAT, lng: ORIGIN_LNG, altitude: 1.8 }, 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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: ['#0ea5e9', '#38bdf8'],
|
||||
name: d.label,
|
||||
value: d.value
|
||||
};
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
const pointsData = useMemo(() => {
|
||||
const points = arcsData.map(arc => ({
|
||||
lat: arc.endLat,
|
||||
lng: arc.endLng,
|
||||
size: 0.1,
|
||||
color: '#38bdf8',
|
||||
name: arc.name,
|
||||
value: arc.value
|
||||
}));
|
||||
|
||||
// Add origin point
|
||||
points.push({
|
||||
lat: ORIGIN_LAT,
|
||||
lng: ORIGIN_LNG,
|
||||
size: 0.2,
|
||||
color: '#f43f5e',
|
||||
name: 'Indonesia (Origin)',
|
||||
value: 0
|
||||
});
|
||||
|
||||
return points;
|
||||
}, [arcsData]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full h-[500px] rounded-xl overflow-visible bg-transparent cursor-move flex items-center justify-center"
|
||||
>
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-black/60 p-3 rounded-lg border border-white/10 backdrop-blur-md">
|
||||
<div className="text-sm font-medium text-white flex items-center gap-2">
|
||||
Global Traffic Flows
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">Interactive 3D visualization of network traffic</p>
|
||||
</div>
|
||||
|
||||
<Globe
|
||||
ref={globeRef}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
globeImageUrl="//unpkg.com/three-globe/example/img/earth-night.jpg"
|
||||
bumpImageUrl="//unpkg.com/three-globe/example/img/earth-topology.png"
|
||||
backgroundColor="rgba(0,0,0,0)"
|
||||
|
||||
// Arcs configuration
|
||||
arcsData={arcsData}
|
||||
arcStartLat={(d: any) => d.startLat}
|
||||
arcStartLng={(d: any) => d.startLng}
|
||||
arcEndLat={(d: any) => d.endLat}
|
||||
arcEndLng={(d: any) => d.endLng}
|
||||
arcColor={(d: any) => d.color}
|
||||
arcDashLength={0.4}
|
||||
arcDashGap={0.2}
|
||||
arcDashAnimateTime={2000}
|
||||
arcAltitude={0.3}
|
||||
arcStroke={0.7}
|
||||
|
||||
// Points configuration
|
||||
pointsData={pointsData}
|
||||
pointLat="lat"
|
||||
pointLng="lng"
|
||||
pointColor="color"
|
||||
pointAltitude={0.01}
|
||||
pointRadius="size"
|
||||
pointsMerge={true}
|
||||
|
||||
// Rings configuration (Ping effect for points)
|
||||
ringsData={pointsData}
|
||||
ringLat="lat"
|
||||
ringLng="lng"
|
||||
ringColor="color"
|
||||
ringMaxRadius={(d: any) => d.name === 'Indonesia (Origin)' ? 3 : 1.5}
|
||||
ringPropagationSpeed={3}
|
||||
ringRepeatPeriod={1000}
|
||||
|
||||
// Labels configuration
|
||||
labelsData={pointsData}
|
||||
labelLat="lat"
|
||||
labelLng="lng"
|
||||
labelText="name"
|
||||
labelSize={1.2}
|
||||
labelDotRadius={0.5}
|
||||
labelColor={() => 'rgba(255, 255, 255, 0.8)'}
|
||||
labelResolution={2}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo, useState, useEffect } from "react";
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Sphere,
|
||||
Graticule,
|
||||
ZoomableGroup,
|
||||
Line,
|
||||
Marker
|
||||
} from "react-simple-maps";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
const COUNTRY_COORDS: Record<string, [number, number]> = {
|
||||
"ID": [113.9213, -0.7893],
|
||||
"US": [-95.7129, 37.0902],
|
||||
"HK": [114.1095, 22.3964],
|
||||
"SG": [103.8198, 1.3521],
|
||||
"JP": [138.2529, 36.2048],
|
||||
"SC": [55.4920, -4.6796],
|
||||
"GB": [-3.4360, 55.3781],
|
||||
"IT": [12.5674, 41.8719],
|
||||
"CN": [104.1954, 35.8617],
|
||||
"KR": [127.7669, 35.9078],
|
||||
"CA": [-106.3468, 56.1304],
|
||||
"LU": [6.1296, 49.8153],
|
||||
"NL": [5.2913, 52.1326],
|
||||
"TR": [35.2433, 38.9637],
|
||||
"UA": [31.1656, 48.3794]
|
||||
};
|
||||
|
||||
interface LineMapProps {
|
||||
data: {
|
||||
countryCode: string;
|
||||
value: number;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const LineMapComponent = ({ data }: LineMapProps) => {
|
||||
const [tooltipContent, setTooltipContent] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
// Origin point: Indonesia (Jakarta)
|
||||
const origin: [number, number] = [106.8456, -6.2088];
|
||||
|
||||
return (
|
||||
<div className="relative w-full aspect-[2/1] bg-secondary/20 rounded-xl overflow-hidden border border-border/50 backdrop-blur-sm">
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-card/80 p-3 rounded-lg border border-border/50 backdrop-blur-md">
|
||||
<div className="text-sm font-medium text-white flex items-center gap-2">
|
||||
Global Connections
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip Overlay */}
|
||||
{tooltipContent && (
|
||||
<div className="absolute bottom-4 left-4 z-10 bg-card/90 backdrop-blur-md border border-border/50 px-4 py-2.5 rounded-lg shadow-lg pointer-events-none animate-fade-in">
|
||||
<div className="text-sm font-semibold text-white">{tooltipContent.split(':')[0]}</div>
|
||||
<div className="text-xs text-sky-400 mt-1 font-medium">{tooltipContent.split(':')[1]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 130,
|
||||
center: [0, 30]
|
||||
}}
|
||||
width={800}
|
||||
height={400}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<ZoomableGroup zoom={1} maxZoom={4} translateExtent={[[-100, -100], [900, 500]]}>
|
||||
<Sphere stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} id="sphere" fill="transparent" />
|
||||
<Graticule stroke="rgba(255,255,255,0.05)" strokeWidth={0.5} />
|
||||
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill="#1e293b"
|
||||
stroke="rgba(255,255,255,0.1)"
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
hover: { outline: "none", fill: "#1e293b" },
|
||||
pressed: { outline: "none" }
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Geographies>
|
||||
|
||||
{data.map((d, i) => {
|
||||
if (d.countryCode === "ID") return null;
|
||||
const dest = COUNTRY_COORDS[d.countryCode];
|
||||
if (!dest) return null;
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
<Line
|
||||
from={origin}
|
||||
to={dest}
|
||||
stroke="#38bdf8"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
style={{ opacity: 0.6 }}
|
||||
/>
|
||||
<Marker coordinates={dest}>
|
||||
<circle
|
||||
r={3}
|
||||
fill="#38bdf8"
|
||||
onMouseEnter={() => setTooltipContent(`${d.label}: ${fmtBytes(d.value)}`)}
|
||||
onMouseLeave={() => setTooltipContent("")}
|
||||
className="cursor-pointer hover:fill-blue-500 transition-colors"
|
||||
/>
|
||||
</Marker>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Origin Marker */}
|
||||
<Marker coordinates={origin}>
|
||||
<circle r={4} fill="#f43f5e" />
|
||||
<circle r={8} fill="#f43f5e" opacity={0.3} className="animate-ping" />
|
||||
</Marker>
|
||||
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LineMap = memo(LineMapComponent);
|
||||
+1
-1
@@ -11,7 +11,7 @@ export function middleware(request: NextRequest) {
|
||||
// The actual API routes inside /api are protected by Express backend (or Next.js API routes)
|
||||
// We don't intercept /api requests here to avoid breaking them, but we protect the UI pages
|
||||
|
||||
if (pathname.startsWith('/api') || pathname.startsWith('/_next') || pathname === '/favicon.ico' || pathname.endsWith('.svg')) {
|
||||
if (pathname.startsWith('/api') || pathname.startsWith('/_next') || pathname === '/favicon.ico' || pathname.endsWith('.svg') || pathname.endsWith('.png')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user