204 lines
10 KiB
TypeScript
204 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { fetchAppDetails, AppDetails } from "@/lib/api";
|
|
import { fmtBytes } from "@/lib/utils";
|
|
import { Loader2, X, Globe, Server, Monitor } from "lucide-react";
|
|
|
|
interface Props {
|
|
appLabel: string;
|
|
favicon?: string | null;
|
|
category?: string | null;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function AppDetailModal({ appLabel, favicon, category, onClose }: Props) {
|
|
const [data, setData] = useState<AppDetails | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isClosing, setIsClosing] = useState(false);
|
|
|
|
const handleClose = () => {
|
|
setIsClosing(true);
|
|
setTimeout(() => {
|
|
onClose();
|
|
}, 480);
|
|
};
|
|
|
|
useEffect(() => {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
fetchAppDetails(appLabel)
|
|
.then(setData)
|
|
.catch((e) => setError(e.message))
|
|
.finally(() => setIsLoading(false));
|
|
}, [appLabel]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
|
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
|
<div
|
|
className={`relative bg-card border border-border rounded-2xl w-full max-w-2xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-green-900/30 to-emerald-900/20 rounded-t-2xl">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-green-500/20 rounded-lg ring-1 ring-green-500/30">
|
|
{favicon ? (
|
|
<img src={favicon} alt={appLabel} className="w-5 h-5 rounded" />
|
|
) : (
|
|
<Globe className="w-5 h-5 text-green-400" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-bold text-card-foreground">{appLabel}</h3>
|
|
{category && <p className="text-xs text-muted-foreground">{category}</p>}
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleClose}
|
|
className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all flex-shrink-0"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto 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-green-400" />
|
|
<p className="text-muted-foreground text-sm">Loading application data...</p>
|
|
</div>
|
|
) : error ? (
|
|
<div className="p-6 text-center text-red-400 text-sm">{error}</div>
|
|
) : data ? (
|
|
<div className="p-6 space-y-6">
|
|
|
|
{/* Total Summary */}
|
|
<div className="grid grid-cols-2 gap-3 p-4 bg-white/[0.03] border border-border/50 rounded-xl">
|
|
<div className="text-center">
|
|
<p className="text-xs text-muted-foreground">Total Download</p>
|
|
<p className="text-xl font-bold text-cyan-400">{fmtBytes(data.total_download ?? 0)}</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-xs text-muted-foreground">Total Upload</p>
|
|
<p className="text-xl font-bold text-green-400">{fmtBytes(data.total_upload ?? 0)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Per-Agent Breakdown */}
|
|
<div>
|
|
<h4 className="text-sm font-semibold text-slate-300 mb-3 flex items-center gap-2">
|
|
<Server className="w-4 h-4 text-blue-400" />
|
|
Usage per Agent
|
|
</h4>
|
|
{data.agent_scorecard.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm text-center py-4">No traffic from known agents for this app yet.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{data.agent_scorecard.map((a, i) => {
|
|
const maxDl = Math.max(...data.agent_scorecard.map(x => x.download), 1);
|
|
const pct = (a.download / maxDl) * 100;
|
|
return (
|
|
<div key={i} className="p-3 bg-white/[0.03] border border-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>
|
|
</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>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|