feat(source2): push all latest files - device labeling, help system, proxy docs, database isolation fix
- Added DOKUMENTASI-FILTER-PER-SITE.md (site isolation docs) - Fixed start-with-env.js to force-load .env.production - Fixed MONGODB_URI hostname from mongodb-netify to mongodb.prod.proit.id - Updated .gitignore to exclude sensitive scripts and credential files - Minor UI and labeling improvements
This commit is contained in:
1 parent
a403f752f3
commit
dd4c8f6876
385 files changed
+31016
-8268
No files matched your search
@@ -3,7 +3,7 @@
|
||||
import { AgentModals } from "@/components/admin/AgentModals";
|
||||
import ExternalAccountModal from "@/components/admin/ExternalAccountModal";
|
||||
import AgentLocationModal from "@/components/admin/AgentLocationModal";
|
||||
import { Agent } from "@/lib/actions/agents";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
import { type ManagedUser } from "@/lib/admin-api";
|
||||
|
||||
interface AgentsPageModalsProps {
|
||||
@@ -101,6 +101,7 @@ export default function AgentsPageModals({
|
||||
user={externalModalUser}
|
||||
targetSiteUuid={targetSiteUuid}
|
||||
targetCreatedBy={targetCreatedBy}
|
||||
modalContext="agents"
|
||||
/>
|
||||
|
||||
<AgentLocationModal
|
||||
|
||||
@@ -2,24 +2,37 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Agent } from "@/lib/actions/agents";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
|
||||
interface AgentsTableSectionProps {
|
||||
agents: Agent[];
|
||||
isLoading: boolean;
|
||||
columns: Column<Agent>[];
|
||||
role: string | null;
|
||||
onProvisionClick?: () => void;
|
||||
}
|
||||
|
||||
export default function AgentsTableSection({
|
||||
agents,
|
||||
isLoading,
|
||||
columns,
|
||||
role,
|
||||
onProvisionClick,
|
||||
}: AgentsTableSectionProps) {
|
||||
return (
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-white">Provisioned Network Agents ({agents.length})</CardTitle>
|
||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && onProvisionClick && (
|
||||
<button
|
||||
onClick={onProvisionClick}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-blue-500/10"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Provision Agent
|
||||
</button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function ExternalAccountsSection({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add External Account
|
||||
Add Technical Account
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -91,7 +91,7 @@ export default function ExternalAccountsSection({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add External Account
|
||||
Add Technical Account
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -117,21 +117,21 @@ export default function ExternalAccountsSection({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Table 3: Nexus Accounts */}
|
||||
{/* Table 3: Office Accounts */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-white">Nexus Tenant External Accounts ({nexusUsers.length})</CardTitle>
|
||||
<CardTitle className="text-white">Office Tenant External Accounts ({nexusUsers.length})</CardTitle>
|
||||
<button
|
||||
onClick={() => {
|
||||
setExternalModalUser(null);
|
||||
setTargetSiteUuid('d7902405_0dc2_458b_8584_ed4d24b64f24');
|
||||
setTargetCreatedBy('nexus');
|
||||
setTargetSiteUuid('1959bb55_045b_47c7_bbdd_f33b7db197b9');
|
||||
setTargetCreatedBy('office');
|
||||
setExternalModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add External Account
|
||||
Add Technical Account
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -175,7 +175,7 @@ export default function ExternalAccountsSection({
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add External Account
|
||||
Add Technical Account
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -203,5 +203,40 @@ export default function ExternalAccountsSection({
|
||||
);
|
||||
}
|
||||
|
||||
// EXECUTIVE: read-only view of all accounts, no create/edit buttons
|
||||
if (role === 'EXECUTIVE') {
|
||||
return (
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<span>External Accounts Directory ({externalUsers.length})</span>
|
||||
</CardTitle>
|
||||
<span className="text-xs text-slate-500 italic px-3 py-1.5 border border-slate-700/50 rounded-lg">Read-Only View</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={externalUsers}
|
||||
columns={externalColumns}
|
||||
searchPlaceholder="Search accounts..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
(row.username || "").toLowerCase().includes(q) ||
|
||||
(row.account_name || "").toLowerCase().includes(q) ||
|
||||
(row.role || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -21,29 +21,29 @@ export default function UptimeStatsCards({
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Server className="w-12 h-12 text-blue-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Total Agents</span>
|
||||
<span className="text-2xl font-black text-white tracking-tight">{totalAgents}</span>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Total Agents</span>
|
||||
<span className="text-2xl font-bold text-white tracking-tight">{totalAgents}</span>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<CheckCircle2 className="w-12 h-12 text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Online Agents</span>
|
||||
<span className="text-2xl font-black text-emerald-400 tracking-tight">{onlineAgents}</span>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Online Agents</span>
|
||||
<span className="text-2xl font-bold text-emerald-400 tracking-tight">{onlineAgents}</span>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<XCircle className="w-12 h-12 text-red-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Offline Agents</span>
|
||||
<span className="text-2xl font-black text-red-400 tracking-tight">{offlineAgents}</span>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Offline Agents</span>
|
||||
<span className="text-2xl font-bold text-red-400 tracking-tight">{offlineAgents}</span>
|
||||
</div>
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm border border-slate-800/80 p-5 rounded-2xl flex flex-col relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-3 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Activity className="w-12 h-12 text-cyan-400" />
|
||||
</div>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1">Avg. Historical Uptime</span>
|
||||
<span className="text-2xl font-black text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span>
|
||||
<span className="text-slate-400 uppercase tracking-wider mb-1 text-[10px] font-medium">Avg. Historical Uptime</span>
|
||||
<span className="text-2xl font-bold text-cyan-400 tracking-tight">{avgUptime.toFixed(2)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
import { Agent } from "@/lib/actions/agents";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
|
||||
import { Loader2, Trash2, CheckCircle2, XCircle, ChevronRight, UserCog, Eye, MapPin } from "lucide-react";
|
||||
@@ -17,8 +17,9 @@ interface GetColumnsProps {
|
||||
setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void;
|
||||
handleViewAs: (uuid: string, label: string) => void;
|
||||
openDelete: (agent: Agent) => void;
|
||||
onEditLocation?: (agentUuid: string, agentLabel: string) => void;
|
||||
locations?: { agent_uuid: string; latitude: number; longitude: number; label?: string }[];
|
||||
handleEditLocation?: (agentUuid: string, agentLabel: string) => void;
|
||||
/** Set of agent UUIDs that already have a location saved */
|
||||
locationUuids?: Set<string>;
|
||||
}
|
||||
|
||||
export function getAgentColumns({
|
||||
@@ -32,14 +33,13 @@ export function getAgentColumns({
|
||||
setAccountModalAgent,
|
||||
handleViewAs,
|
||||
openDelete,
|
||||
onEditLocation,
|
||||
locations = [],
|
||||
handleEditLocation,
|
||||
locationUuids = new Set(),
|
||||
}: GetColumnsProps): Column<Agent>[] {
|
||||
const isSuperAdmin = role === 'SUPER_ADMIN';
|
||||
const cols: Column<Agent>[] = [
|
||||
{
|
||||
header: "UUID / Serial",
|
||||
className: isSuperAdmin ? "w-[12%] text-center" : "w-[14%] text-center",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) => (
|
||||
<button onClick={() => setDetailAgent(row)} className="flex items-center justify-center gap-2 group text-center mx-auto" title="Click to view agent details">
|
||||
<span className="font-mono text-xs text-blue-400 group-hover:text-blue-300 transition-colors">{row.uuid || row.serial}</span>
|
||||
@@ -49,14 +49,19 @@ export function getAgentColumns({
|
||||
},
|
||||
{
|
||||
header: "Label",
|
||||
className: isSuperAdmin ? "w-[18%] text-center" : "w-[24%] text-center",
|
||||
className: "w-[15%] text-center",
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||
const hasAccount = managedUsers.some(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER');
|
||||
const displayLabel = dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid);
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 mx-auto max-w-full">
|
||||
<button onClick={() => setDetailAgent(row)} className="text-center hover:text-white transition-colors truncate">
|
||||
<button
|
||||
onClick={() => setDetailAgent(row)}
|
||||
className="text-center hover:text-white transition-colors truncate"
|
||||
title={displayLabel}
|
||||
>
|
||||
{dynamicLabel !== uuid ? dynamicLabel : (row.label || <span className="text-muted-foreground italic font-mono text-xs">{uuid}</span>)}
|
||||
</button>
|
||||
{!hasAccount && (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||
@@ -68,30 +73,33 @@ export function getAgentColumns({
|
||||
},
|
||||
{
|
||||
header: "Provisioned",
|
||||
className: isSuperAdmin ? "w-[10%] text-center" : "w-[11%] text-center",
|
||||
className: "w-[11%] text-center",
|
||||
accessor: (row) => row.provisioned ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
|
||||
},
|
||||
{
|
||||
header: "Activated",
|
||||
className: isSuperAdmin ? "w-[10%] text-center" : "w-[11%] text-center",
|
||||
className: "w-[11%] text-center",
|
||||
accessor: (row) => row.activated ? <Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Yes</Badge> : <Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> No</Badge>
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
className: "w-[12%] text-center",
|
||||
header: "Traffic Status",
|
||||
className: "w-[15%] text-center",
|
||||
accessor: (row) => {
|
||||
const statusText = row.last_seen_at?.human || '-';
|
||||
if (statusText === 'Online') {
|
||||
return <span className="text-emerald-400 font-medium flex items-center justify-center gap-1.5"><div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div> Online</span>;
|
||||
} else if (statusText === 'Offline') {
|
||||
return <span className="text-red-400 font-medium">Offline</span>;
|
||||
if (statusText.startsWith('Last seen:')) {
|
||||
return (
|
||||
<span className="text-emerald-400 font-medium flex items-center justify-center gap-1.5 mx-auto">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||
{statusText}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return <span className="text-slate-400 text-xs">{statusText}</span>;
|
||||
return <span className="text-slate-400 text-xs font-normal">{statusText}</span>;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Historical Uptime",
|
||||
header: "Avg Uptime",
|
||||
className: "w-[10%] text-center",
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
@@ -99,60 +107,75 @@ export function getAgentColumns({
|
||||
let color = "text-emerald-400";
|
||||
if (uptime < 90) color = "text-red-400";
|
||||
else if (uptime < 98) color = "text-amber-400";
|
||||
return <span className={`font-mono font-bold ${color}`}>{uptime.toFixed(1)}%</span>;
|
||||
return <span className={`font-mono font-medium text-xs tracking-wide ${color}`}>{uptime.toFixed(1)}%</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
className: "w-[18%] text-center",
|
||||
className: "w-[16%] text-center",
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
const dynamicLabel = resolveAgentLabel(uuid, managedUsers);
|
||||
const existingUser = managedUsers.find(u => u.agent_uuid === uuid && u.role === 'AGENT_VIEWER') || null;
|
||||
const isViewAsLoading = viewAsLoading === uuid;
|
||||
const agentLabel = dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid);
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<button onClick={() => setDetailAgent(row)} className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors" title="Detail Agent">
|
||||
<div className="flex items-center justify-center gap-1 mx-auto">
|
||||
<button onClick={() => setDetailAgent(row)} className="p-1 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors" title="Detail Agent">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||
<>
|
||||
{onEditLocation && (() => {
|
||||
const hasCoords = locations.some(l => l.agent_uuid === uuid);
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEditLocation(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
|
||||
className={`p-1.5 rounded transition-colors ${
|
||||
hasCoords
|
||||
? "text-blue-400 hover:text-blue-300 hover:bg-blue-950/30"
|
||||
: "text-red-400/60 hover:text-red-400 hover:bg-red-950/30"
|
||||
}`}
|
||||
title={hasCoords ? "Edit Geolocation (Dikonfigurasi)" : "Configure Geolocation (Belum Dikonfigurasi)"}
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid) }); setAccountModalOpen(true); }}
|
||||
className="p-1.5 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); setAccountModalAgent({ uuid, label: agentLabel }); setAccountModalOpen(true); }}
|
||||
className="p-1 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-950/30 rounded transition-colors"
|
||||
title={existingUser ? 'Edit Agent Account' : 'Create Agent Account'}
|
||||
>
|
||||
<UserCog className="w-4 h-4" />
|
||||
</button>
|
||||
{handleEditLocation && (() => {
|
||||
const hasLocation = locationUuids.has(uuid);
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditLocation(uuid, agentLabel); }}
|
||||
className={`relative p-1 rounded transition-colors ${
|
||||
hasLocation
|
||||
? "text-teal-400 hover:text-teal-300 hover:bg-teal-950/30"
|
||||
: "text-slate-500 hover:text-indigo-400 hover:bg-indigo-950/30"
|
||||
}`}
|
||||
title={hasLocation ? "✅ Location set — click to update" : "⚠️ No location — click to set coordinates"}
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
{hasLocation && (
|
||||
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-teal-400 border border-slate-900" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, dynamicLabel !== uuid ? dynamicLabel : (row.label || uuid)); }}
|
||||
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, agentLabel); }}
|
||||
disabled={isViewAsLoading}
|
||||
className="p-1.5 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50"
|
||||
className="p-1 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50"
|
||||
title="View As Agent"
|
||||
>
|
||||
{isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors" title="Delete">
|
||||
<button onClick={(e) => { e.stopPropagation(); openDelete(row); }} className="p-1 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors" title="Delete">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* COMPANY_ADMIN dan COMPANY_OPERATOR: hanya View-As */}
|
||||
{(role === 'COMPANY_ADMIN' || role === 'COMPANY_OPERATOR') && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleViewAs(uuid, agentLabel); }}
|
||||
disabled={isViewAsLoading}
|
||||
className="p-1 text-amber-400 hover:text-amber-300 hover:bg-amber-950/30 rounded transition-colors disabled:opacity-50"
|
||||
title="View As Agent — see dashboard from this agent's perspective"
|
||||
>
|
||||
{isViewAsLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -166,7 +189,15 @@ export function getAgentColumns({
|
||||
accessor: (row) => {
|
||||
const uuid = row.uuid || row.serial;
|
||||
const sizeMB = storageMap[uuid] ?? 0;
|
||||
return <span className="font-mono text-slate-300 font-semibold">{formatStorageSize(sizeMB)}</span>;
|
||||
let displaySize = "";
|
||||
if (sizeMB >= 1024 * 1024) {
|
||||
displaySize = `${(sizeMB / (1024 * 1024)).toFixed(2)} TB`;
|
||||
} else if (sizeMB >= 1024) {
|
||||
displaySize = `${(sizeMB / 1024).toFixed(2)} GB`;
|
||||
} else {
|
||||
displaySize = `${sizeMB.toFixed(2)} MB`;
|
||||
}
|
||||
return <span className="font-mono text-xs font-normal text-slate-300 tracking-wide">{displaySize}</span>;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -174,17 +205,4 @@ export function getAgentColumns({
|
||||
return cols;
|
||||
}
|
||||
|
||||
function formatStorageSize(sizeMB: number): string {
|
||||
if (sizeMB === 0) return "0.00 MB";
|
||||
if (sizeMB >= 1024 * 1024) {
|
||||
return `${(sizeMB / (1024 * 1024)).toFixed(2)} TB`;
|
||||
}
|
||||
if (sizeMB >= 1024) {
|
||||
return `${(sizeMB / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
if (sizeMB < 1) {
|
||||
return `${(sizeMB * 1024).toFixed(2)} KB`;
|
||||
}
|
||||
return `${sizeMB.toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { SafeImage } from "@/components/ui/SafeImage";
|
||||
import { type ManagedUser } from "@/lib/admin-api";
|
||||
import { UserCog, CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
@@ -16,7 +17,12 @@ export function getExternalAccountColumns(
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 shrink-0 overflow-hidden">
|
||||
{row.profile_picture ? (
|
||||
<img src={`/api/uploads/${row.profile_picture}`} alt={row.account_name || row.username} className="w-full h-full object-cover" />
|
||||
<SafeImage
|
||||
src={`/api/uploads/${row.profile_picture}`}
|
||||
alt={row.account_name || row.username}
|
||||
fallbackName={row.account_name || row.username}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-slate-400">{(row.account_name || row.username).substring(0, 2).toUpperCase()}</span>
|
||||
)}
|
||||
@@ -36,6 +42,7 @@ export function getExternalAccountColumns(
|
||||
accessor: (row) => {
|
||||
let badgeColor = "bg-slate-500/20 text-slate-400 border-slate-500/30";
|
||||
if (row.role === 'SUPER_ADMIN') badgeColor = "bg-purple-500/20 text-purple-400 border-purple-500/30";
|
||||
if (row.role === 'EXECUTIVE') badgeColor = "bg-yellow-500/20 text-yellow-400 border-yellow-500/30";
|
||||
if (row.role === 'SOC_ANALYST') badgeColor = "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||
if (row.role === 'ENGINEER') badgeColor = "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
if (row.role === 'TENANT_ADMIN') badgeColor = "bg-emerald-500/20 text-emerald-400 border-emerald-500/30";
|
||||
|
||||
@@ -1,32 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Plus, Server } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Plus, Loader2, MapPin } from "lucide-react";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import { useAgentsData } from "./useAgentsData";
|
||||
import { getAgentColumns } from "./columns";
|
||||
import { getExternalAccountColumns } from "./externalColumns";
|
||||
import ExternalAccountsSection from "./ExternalAccountsSection";
|
||||
import UptimeStatsCards from "./UptimeStatsCards";
|
||||
import AgentsTableSection from "./AgentsTableSection";
|
||||
import ExternalAccountsSection from "./ExternalAccountsSection";
|
||||
import AgentsPageModals from "./AgentsPageModals";
|
||||
import { useAgentsData } from "./useAgentsData";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const IndonesiaAgentMap = dynamic(
|
||||
() => import("@/components/dashboard/IndonesiaAgentMap").then(m => ({ default: m.IndonesiaAgentMap })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-[320px] w-full rounded-2xl border border-white/5 bg-slate-900/40 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<span className="text-xs text-slate-400">Loading Agent Location Map...</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
import AgentLocationMap from "@/components/admin/AgentLocationMap";
|
||||
import { resolveAgentLabel } from "@/lib/admin-api";
|
||||
|
||||
export default function AgentsPage() {
|
||||
useEffect(() => {
|
||||
document.title = "Agents | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
const {
|
||||
role,
|
||||
agents,
|
||||
@@ -82,46 +72,45 @@ export default function AgentsPage() {
|
||||
setAccountModalAgent,
|
||||
handleViewAs,
|
||||
openDelete,
|
||||
onEditLocation: handleEditLocation,
|
||||
locations,
|
||||
handleEditLocation,
|
||||
locationUuids: new Set(locations.map((l: any) => l.agent_uuid)),
|
||||
});
|
||||
|
||||
const totalAgents = agents.length;
|
||||
const onlineAgents = agents.filter(a => a.last_seen_at?.human === 'Online').length;
|
||||
const offlineAgents = totalAgents - onlineAgents;
|
||||
const uptimeValues = agents.map(a => uptimeMap[a.uuid || a.serial] ?? 100);
|
||||
const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100;
|
||||
|
||||
const externalUsers = managedUsers.filter(u => u.role !== 'AGENT_VIEWER');
|
||||
const superadminUsers = externalUsers.filter(u => u.role === 'SUPER_ADMIN' || !u.site_uuid || u.site_uuid === 'default' || (u.site_uuid !== '6681452d_9cae_4ff4_8ae8_0d504774265e' && u.site_uuid !== 'd7902405_0dc2_458b_8584_ed4d24b64f24'));
|
||||
const siabUsers = externalUsers.filter(u => u.role !== 'SUPER_ADMIN' && u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e');
|
||||
const nexusUsers = externalUsers.filter(u => u.role !== 'SUPER_ADMIN' && u.site_uuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24');
|
||||
|
||||
const externalColumns = getExternalAccountColumns((user) => {
|
||||
setExternalModalUser(user);
|
||||
setExternalModalOpen(true);
|
||||
});
|
||||
|
||||
const totalAgents = agents.length;
|
||||
const onlineAgents = agents.filter(a => {
|
||||
const hum = a.last_seen_at?.human;
|
||||
return hum === 'Online' || hum === 'Flows Detected' || (hum && hum.startsWith('Last seen:'));
|
||||
}).length;
|
||||
const offlineAgents = totalAgents - onlineAgents;
|
||||
const uptimeValues = agents.map(a => uptimeMap[a.uuid || a.serial] ?? 100);
|
||||
const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100;
|
||||
|
||||
// External Accounts di halaman Agents = akun eksternal teknikal/operasional saja
|
||||
// AGENT_VIEWER = akun network agent (sudah ada di tabel agents) → BUKAN akun eksternal
|
||||
// COMPANY_* = akun customer/client → sudah ada di halaman User Accounts
|
||||
const EXCLUDED_ROLES = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER', 'AGENT_VIEWER'];
|
||||
const externalUsers = managedUsers.filter(u => !EXCLUDED_ROLES.includes(u.role || ''));
|
||||
// Grouping by site — semua sudah dipastikan hanya SUPER_ADMIN, EXECUTIVE, TENANT_ADMIN, SOC_ANALYST, ENGINEER
|
||||
const superadminUsers = externalUsers.filter(u => u.role === 'SUPER_ADMIN' || u.role === 'EXECUTIVE' || !u.site_uuid || u.site_uuid === 'default');
|
||||
const siabUsers = externalUsers.filter(u => u.site_uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e' && u.role !== 'SUPER_ADMIN' && u.role !== 'EXECUTIVE');
|
||||
const nexusUsers = externalUsers.filter(u => u.site_uuid === '1959bb55_045b_47c7_bbdd_f33b7db197b9' && u.role !== 'SUPER_ADMIN' && u.role !== 'EXECUTIVE');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
<Server className="w-6 h-6 text-primary" />
|
||||
Agents Inventory
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<HelpTrigger pageId="agents" />
|
||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||
<button
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/95 text-primary-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all shadow-lg shadow-blue-500/10"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Provision Agent
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-white flex items-center gap-3">
|
||||
Agents Inventory
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">Managed DPI probes, remote collectors, and probe infrastructure.</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="agents" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -137,35 +126,79 @@ export default function AgentsPage() {
|
||||
avgUptime={avgUptime}
|
||||
/>
|
||||
|
||||
{/* Indonesia Agent Geolocation & Flow Map */}
|
||||
{!isLoading && (
|
||||
<IndonesiaAgentMap
|
||||
agents={agents}
|
||||
locations={locations}
|
||||
onEditLocation={handleEditLocation}
|
||||
role={role}
|
||||
/>
|
||||
{/* Agent Location Map — tampil untuk SUPER_ADMIN, TENANT_ADMIN, dan COMPANY_ADMIN */}
|
||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN' || role === 'COMPANY_ADMIN') && (
|
||||
<div className="rounded-2xl border border-white/8 bg-slate-900/40 overflow-hidden relative z-0">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-white/8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MapPin className="w-4 h-4 text-indigo-400" />
|
||||
<span className="text-sm font-semibold text-white">Agent Network Map</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
{locations.length > 0
|
||||
? `${locations.length} of ${agents.length} agent${agents.length !== 1 ? 's' : ''} positioned`
|
||||
: 'No positions configured yet'}
|
||||
</span>
|
||||
</div>
|
||||
{agents.length > locations.length && (
|
||||
<span className="text-[11px] text-amber-400 bg-amber-400/10 border border-amber-400/20 px-2.5 py-1 rounded-full">
|
||||
{agents.length - locations.length} agent{agents.length - locations.length !== 1 ? 's' : ''} without location — click 📍 in the table
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<AgentLocationMap
|
||||
pins={locations
|
||||
.filter(loc => agents.some(a => (a.uuid || a.serial) === loc.agent_uuid))
|
||||
.map(loc => {
|
||||
const agent = agents.find(a => (a.uuid || a.serial) === loc.agent_uuid);
|
||||
const agentLabel = agent
|
||||
? (resolveAgentLabel(loc.agent_uuid, managedUsers) !== loc.agent_uuid
|
||||
? resolveAgentLabel(loc.agent_uuid, managedUsers)
|
||||
: (agent.label || loc.label || loc.agent_uuid))
|
||||
: (loc.label || loc.agent_uuid);
|
||||
const isOnline = !!(agent?.last_seen_at?.human === 'Online' ||
|
||||
agent?.last_seen_at?.human === 'Flows Detected' ||
|
||||
(agent?.last_seen_at?.human && agent.last_seen_at.human.startsWith('Last seen:')));
|
||||
const uptime = uptimeMap[loc.agent_uuid];
|
||||
return {
|
||||
agent_uuid: loc.agent_uuid,
|
||||
label: agentLabel,
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
isOnline,
|
||||
uptimePercent: uptime,
|
||||
};
|
||||
})}
|
||||
onEditLocation={handleEditLocation}
|
||||
canEdit={role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AgentsTableSection
|
||||
agents={agents}
|
||||
isLoading={isLoading}
|
||||
columns={columns}
|
||||
role={role}
|
||||
onProvisionClick={() => setIsCreateOpen(true)}
|
||||
/>
|
||||
|
||||
<ExternalAccountsSection
|
||||
role={role}
|
||||
isLoading={isLoading}
|
||||
externalUsers={externalUsers}
|
||||
superadminUsers={superadminUsers}
|
||||
siabUsers={siabUsers}
|
||||
nexusUsers={nexusUsers}
|
||||
externalColumns={externalColumns}
|
||||
setExternalModalUser={setExternalModalUser}
|
||||
setTargetSiteUuid={setTargetSiteUuid}
|
||||
setTargetCreatedBy={setTargetCreatedBy}
|
||||
setExternalModalOpen={setExternalModalOpen}
|
||||
/>
|
||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||
<ExternalAccountsSection
|
||||
role={role}
|
||||
isLoading={isLoading}
|
||||
externalUsers={externalUsers}
|
||||
superadminUsers={superadminUsers}
|
||||
siabUsers={siabUsers}
|
||||
nexusUsers={nexusUsers}
|
||||
externalColumns={externalColumns}
|
||||
setExternalModalUser={setExternalModalUser}
|
||||
setTargetSiteUuid={setTargetSiteUuid}
|
||||
setTargetCreatedBy={setTargetCreatedBy}
|
||||
setExternalModalOpen={setExternalModalOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AgentsPageModals
|
||||
isCreateOpen={isCreateOpen}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getAgents, Agent } from "@/lib/actions/agents";
|
||||
import { getAgents } from "@/lib/actions/agents";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
import { getAdminUsers, startViewAs, getViewAsHeaders, type ManagedUser } from "@/lib/admin-api";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { usePollingKey } from "@/lib/usePolling";
|
||||
@@ -10,6 +11,7 @@ import { usePollingKey } from "@/lib/usePolling";
|
||||
export function useAgentsData() {
|
||||
const router = useRouter();
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [myAgentUuids, setMyAgentUuids] = useState<string[]>([]);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [uptimeMap, setUptimeMap] = useState<Record<string, number>>({});
|
||||
const [storageMap, setStorageMap] = useState<Record<string, number>>({});
|
||||
@@ -89,7 +91,7 @@ export function useAgentsData() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadAgents = async () => {
|
||||
const loadAgents = async (allowedUuids?: string[], currentRole?: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const siteUuid = localStorage.getItem("backone_site_uuid") || undefined;
|
||||
@@ -97,8 +99,18 @@ export function useAgentsData() {
|
||||
getAgents(siteUuid),
|
||||
getAdminUsers(),
|
||||
]);
|
||||
if (agentData.status === 'fulfilled') setAgents(agentData.value);
|
||||
else setError((agentData.reason as Error).message);
|
||||
if (agentData.status === 'fulfilled') {
|
||||
let allAgents = agentData.value;
|
||||
// Filter agents untuk COMPANY_ADMIN dan COMPANY_OPERATOR — hanya tampilkan yang di-assign
|
||||
const uuids = allowedUuids ?? myAgentUuids;
|
||||
const r = currentRole ?? role;
|
||||
if ((r === 'COMPANY_ADMIN' || r === 'COMPANY_OPERATOR') && uuids.length > 0) {
|
||||
allAgents = allAgents.filter(a => uuids.includes(a.uuid || a.serial));
|
||||
}
|
||||
setAgents(allAgents);
|
||||
} else {
|
||||
setError((agentData.reason as Error).message);
|
||||
}
|
||||
if (userData.status === 'fulfilled') setManagedUsers(userData.value);
|
||||
await loadUptime();
|
||||
await loadLocations();
|
||||
@@ -127,11 +139,14 @@ export function useAgentsData() {
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
const userRole = data.user.role || null;
|
||||
const uuids: string[] = data.user.agent_uuids || [];
|
||||
setRole(userRole);
|
||||
if (userRole !== 'SUPER_ADMIN' && userRole !== 'TENANT_ADMIN') {
|
||||
setMyAgentUuids(uuids);
|
||||
const allowed = ['SUPER_ADMIN', 'TENANT_ADMIN', 'EXECUTIVE', 'COMPANY_ADMIN', 'COMPANY_OPERATOR'];
|
||||
if (!allowed.includes(userRole || '')) {
|
||||
router.push('/');
|
||||
} else {
|
||||
loadAgents();
|
||||
loadAgents(uuids, userRole);
|
||||
}
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
@@ -148,7 +163,7 @@ export function useAgentsData() {
|
||||
}, [timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') {
|
||||
if (role === 'SUPER_ADMIN') {
|
||||
loadStorage();
|
||||
}
|
||||
if (role && role !== 'AGENT_VIEWER') {
|
||||
@@ -158,13 +173,8 @@ export function useAgentsData() {
|
||||
|
||||
useEffect(() => {
|
||||
if (role && role !== 'AGENT_VIEWER' && refreshKey > 0) {
|
||||
const siteUuid = localStorage.getItem("backone_site_uuid") || undefined;
|
||||
getAgents(siteUuid)
|
||||
.then(data => setAgents(data))
|
||||
.catch(err => console.warn("Failed to auto-refresh agents:", err));
|
||||
loadUptime();
|
||||
loadLocations();
|
||||
if (role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') {
|
||||
loadAgents();
|
||||
if (role === 'SUPER_ADMIN') {
|
||||
loadStorage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { AppDetailModal } from "@/components/ui/AppDetailModal";
|
||||
import { Loader2, ChevronRight, Globe } from "lucide-react";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import { fmtBytes, getAppFavicon, formatAppLabel } from "@/lib/utils";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import { getViewAsStatusSync } from "@/lib/admin-api";
|
||||
|
||||
export default function AppsPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const pageSize = 20;
|
||||
const { data, isLoading } = useTopApps();
|
||||
const [selectedApp, setSelectedApp] = useState<AppStat | null>(null);
|
||||
|
||||
const {
|
||||
@@ -26,12 +26,9 @@ export default function AppsPage() {
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
// Pass dateFrom/dateTo so the backend filters by the exact calendar date range
|
||||
const { data, isLoading } = useTopApps(pageSize, dateFrom, dateTo);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Apps | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
@@ -54,13 +51,15 @@ export default function AppsPage() {
|
||||
const filteredData = all.filter((row: any) => {
|
||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_label) return false;
|
||||
if (filters.category && filters.category !== "All" && row.category !== filters.category) return false;
|
||||
// Date filtering is handled server-side via date_from/date_to params.
|
||||
// AppStat records do not have a last_seen field, so client-side date
|
||||
// filtering is intentionally omitted here.
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : "";
|
||||
if (dateFrom && d < dateFrom) return false;
|
||||
if (dateTo && d > dateTo) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
@@ -74,10 +73,14 @@ export default function AppsPage() {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? (b.download ?? 0) - (a.download ?? 0) : (a.download ?? 0) - (b.download ?? 0));
|
||||
}
|
||||
|
||||
// Use the data retention window (7 days) as the minimum selectable date.
|
||||
// Dynamic computation from loaded data causes a circular dependency where
|
||||
// the sidebar time filter (e.g., Last 24h) restricts the date picker range.
|
||||
const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const minTime = all.reduce((min: number, r: any) => {
|
||||
if (r.last_seen) {
|
||||
const time = new Date(r.last_seen).getTime();
|
||||
if (isFinite(time) && time < min) return time;
|
||||
}
|
||||
return min;
|
||||
}, Infinity);
|
||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
||||
|
||||
const top5 = processedData.slice(0, 5);
|
||||
|
||||
@@ -124,13 +127,12 @@ export default function AppsPage() {
|
||||
accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span>
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Applications
|
||||
Apps
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
@@ -139,7 +141,7 @@ export default function AppsPage() {
|
||||
</div>
|
||||
<HelpTrigger pageId="apps" />
|
||||
</div>
|
||||
|
||||
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
@@ -182,7 +184,11 @@ export default function AppsPage() {
|
||||
stroke="#8b949e"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => fmtBytes(value)}
|
||||
tickFormatter={(value) => {
|
||||
const gb = value / 1073741824;
|
||||
const mb = value / 1048576;
|
||||
return gb >= 1 ? `${gb.toFixed(1)}GB` : `${mb.toFixed(0)}MB`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{ fill: "#21262d" }}
|
||||
@@ -223,10 +229,9 @@ export default function AppsPage() {
|
||||
appLabel={selectedApp.app_label}
|
||||
favicon={selectedApp.favicon}
|
||||
category={selectedApp.category}
|
||||
agentUuid={getViewAsStatusSync().agent_uuid}
|
||||
onClose={() => setSelectedApp(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { DeviceLabelItem } from "./columns";
|
||||
|
||||
interface EditOwnerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
device: DeviceLabelItem | null;
|
||||
onSaveSuccess: (mac: string, newLabel: string) => void;
|
||||
}
|
||||
|
||||
export default function EditOwnerModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
device,
|
||||
onSaveSuccess,
|
||||
}: EditOwnerModalProps) {
|
||||
const [customLabelValue, setCustomLabelValue] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveSuccessMsg, setSaveSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && device) {
|
||||
setCustomLabelValue(device.custom_label || "");
|
||||
setError(null);
|
||||
setSaveSuccessMsg(null);
|
||||
}
|
||||
}, [isOpen, device]);
|
||||
|
||||
const handleSaveLabel = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!device) return;
|
||||
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSaveSuccessMsg(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/dashboard/devices/update-label", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getViewAsHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mac_address: device.mac_address,
|
||||
device_label: customLabelValue.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
const resData = await res.json();
|
||||
if (resData.ok) {
|
||||
setSaveSuccessMsg("Owner label updated successfully!");
|
||||
onSaveSuccess(device.mac_address, customLabelValue.trim());
|
||||
|
||||
// Delay closing modal slightly so user sees success state
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 800);
|
||||
} else {
|
||||
setError(resData.error || "Failed to update label.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to update device label.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={() => !isSaving && onClose()}
|
||||
title="Edit Device Owner Label"
|
||||
>
|
||||
{device && (
|
||||
<form onSubmit={handleSaveLabel} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
MAC Address
|
||||
</label>
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-lg">
|
||||
<code className="text-sm text-slate-200 font-mono">{device.mac_address}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Default System Label
|
||||
</label>
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-lg text-sm text-slate-400">
|
||||
{device.default_label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-400">
|
||||
Custom Owner Label
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customLabelValue}
|
||||
onChange={(e) => setCustomLabelValue(e.target.value)}
|
||||
placeholder="e.g. Finance Department Laptop"
|
||||
className="w-full bg-white/[0.03] border border-white/10 rounded-lg px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/50 transition-all"
|
||||
disabled={isSaving}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500">
|
||||
Leave empty to clear the custom label and restore the system default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-xs">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveSuccessMsg && (
|
||||
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg text-xs font-semibold">
|
||||
✓ {saveSuccessMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2 border-t border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-transparent hover:bg-white/5 text-slate-300 hover:text-white rounded-lg text-xs font-medium transition-all"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-primary hover:bg-primary/90 text-white rounded-lg text-xs font-semibold shadow-md shadow-primary/10 transition-all"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
|
||||
export interface DeviceLabelItem {
|
||||
mac_address: string;
|
||||
ip_address: string;
|
||||
device_type: string;
|
||||
manufacturer: string;
|
||||
default_label: string;
|
||||
custom_label: string | null;
|
||||
agent_uuid: string;
|
||||
agent_name: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
interface GetColumnsProps {
|
||||
userRole: string;
|
||||
onEditClick: (device: DeviceLabelItem) => void;
|
||||
onMacClick: (device: DeviceLabelItem) => void;
|
||||
}
|
||||
|
||||
export function getColumns({
|
||||
userRole,
|
||||
onEditClick,
|
||||
onMacClick,
|
||||
}: GetColumnsProps): Column<DeviceLabelItem>[] {
|
||||
return [
|
||||
{
|
||||
header: "#",
|
||||
className: "w-[4%] text-center",
|
||||
accessor: (_, i) => <span className="text-slate-400 font-mono">{i + 1}</span>
|
||||
},
|
||||
{
|
||||
header: "MAC Address",
|
||||
className: "w-[15%] text-center",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMacClick(row)}
|
||||
className="text-xs bg-white/5 border border-white/10 hover:border-amber-500/30 hover:bg-amber-500/5 px-2 py-0.5 rounded font-mono text-slate-300 hover:text-amber-400 transition-all cursor-pointer outline-none"
|
||||
title="Click to view device details & IP history"
|
||||
>
|
||||
{row.mac_address}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Custom Owner Label",
|
||||
className: "w-[20%] text-center",
|
||||
accessor: (row) => row.custom_label ? (
|
||||
<span className="text-xs font-semibold text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2.5 py-1 rounded-full">
|
||||
{row.custom_label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic">No label configured</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Default System Label",
|
||||
className: "w-[20%] text-center",
|
||||
accessor: (row) => (
|
||||
<span className="text-xs text-slate-400">
|
||||
{row.default_label}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Network Agent",
|
||||
className: "w-[18%] text-center",
|
||||
accessor: (row) => (
|
||||
<span className="text-xs font-semibold text-purple-400 bg-purple-500/10 border border-purple-500/20 px-2.5 py-1 rounded-full">
|
||||
{row.agent_name}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Last IP Address",
|
||||
className: "w-[11%] text-center",
|
||||
accessor: (row) => (
|
||||
<span className="text-xs font-mono text-slate-400">
|
||||
{row.ip_address}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) => userRole === 'EXECUTIVE' ? (
|
||||
<span className="text-xs text-slate-500 italic">View Only</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEditClick(row)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 border border-blue-500/20 hover:border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10 px-3 py-1.5 rounded-lg transition-all font-medium cursor-pointer"
|
||||
>
|
||||
✏️ Edit Owner
|
||||
</button>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { Loader2, Tag } from "lucide-react";
|
||||
import { getColumns, DeviceLabelItem } from "./columns";
|
||||
import EditOwnerModal from "./EditOwnerModal";
|
||||
import DeviceMacDetailsModal from "@/components/admin/DeviceMacDetailsModal";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
export default function DeviceLabelingPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [devices, setDevices] = useState<DeviceLabelItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userRole, setUserRole] = useState<string>("");
|
||||
|
||||
const { timeRange } = useTimeFilter();
|
||||
|
||||
// Modal states
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [editingDevice, setEditingDevice] = useState<DeviceLabelItem | null>(null);
|
||||
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [detailedDevice, setDetailedDevice] = useState<DeviceLabelItem | null>(null);
|
||||
|
||||
const fetchDevices = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/devices/labeling?timeRange=${timeRange}`, {
|
||||
headers: getViewAsHeaders(),
|
||||
});
|
||||
const resData = await res.json();
|
||||
if (resData.ok) {
|
||||
setDevices(resData.data || []);
|
||||
} else {
|
||||
setError(resData.error || "Failed to load device listing.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "An error occurred while fetching devices.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Device Labeling | BackOne - Deep Package Inspection";
|
||||
// Fetch current user role to conditionally show/hide edit controls
|
||||
fetch("/api/auth/me", { headers: getViewAsHeaders() })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.user?.role) setUserRole(data.user.role);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
fetchDevices();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timeRange, mounted]);
|
||||
|
||||
const handleEditClick = (device: DeviceLabelItem) => {
|
||||
setEditingDevice(device);
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const handleMacClick = (device: DeviceLabelItem) => {
|
||||
setDetailedDevice(device);
|
||||
setIsDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveSuccess = (mac: string, newLabel: string) => {
|
||||
setDevices((prev) =>
|
||||
prev.map((d) =>
|
||||
d.mac_address === mac
|
||||
? { ...d, custom_label: newLabel || null }
|
||||
: d
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const searchFilter = (row: DeviceLabelItem, q: string) => {
|
||||
const queryStr = q.toLowerCase();
|
||||
return (
|
||||
row.mac_address.toLowerCase().includes(queryStr) ||
|
||||
row.ip_address.toLowerCase().includes(queryStr) ||
|
||||
row.default_label.toLowerCase().includes(queryStr) ||
|
||||
(row.custom_label || "").toLowerCase().includes(queryStr)
|
||||
);
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const columns = getColumns({
|
||||
userRole,
|
||||
onEditClick: handleEditClick,
|
||||
onMacClick: handleMacClick,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Device Labeling
|
||||
<Tag className="w-6 h-6 text-primary" />
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Assign custom names to MAC addresses to identify device owners across all charts and flows.
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="device-labeling" />
|
||||
</div>
|
||||
|
||||
<Card className="border-white/5 bg-black/40 backdrop-blur-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold tracking-wide uppercase text-slate-300">
|
||||
Device Asset Directory
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && !isEditOpen && !isDetailOpen && (
|
||||
<div className="mb-6 p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-sm">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
data={devices}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search MAC, IP, vendor, or custom label..."
|
||||
searchFilter={searchFilter}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Owner Modal */}
|
||||
<EditOwnerModal
|
||||
isOpen={isEditOpen}
|
||||
onClose={() => {
|
||||
setIsEditOpen(false);
|
||||
setEditingDevice(null);
|
||||
}}
|
||||
device={editingDevice}
|
||||
onSaveSuccess={handleSaveSuccess}
|
||||
/>
|
||||
|
||||
{/* Device Mac Details Modal */}
|
||||
{detailedDevice && (
|
||||
<DeviceMacDetailsModal
|
||||
isOpen={isDetailOpen}
|
||||
onClose={() => {
|
||||
setIsDetailOpen(false);
|
||||
setDetailedDevice(null);
|
||||
}}
|
||||
macAddress={detailedDevice.mac_address}
|
||||
customLabel={detailedDevice.custom_label}
|
||||
defaultLabel={detailedDevice.default_label}
|
||||
agentName={detailedDevice.agent_name}
|
||||
manufacturer={detailedDevice.manufacturer}
|
||||
deviceType={detailedDevice.device_type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { DeviceStat } from "@/lib/api";
|
||||
import { useDevices } from "@/lib/api-with-context";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { DeviceStat } from "@/lib/api";
|
||||
import { useDevices } from "@/lib/api-with-context";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
||||
|
||||
const {
|
||||
filters, dateFrom, dateTo,
|
||||
handleFilterChange, setDateFrom, setDateTo,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
// Pass dateFrom/dateTo so the backend filters by the exact calendar date range
|
||||
const devices = useDevices(5000, dateFrom, dateTo);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const all = devices.data || [];
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "device_type", label: "Device Type", type: "select", value: filters.device_type, options: opts("device_type") },
|
||||
{ id: "os_label", label: "OS", type: "select", value: filters.os_label, options: opts("os_label") },
|
||||
{ id: "manufacturer", label: "Manufacturer", type: "select", value: filters.manufacturer, options: opts("manufacturer") },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const columns: Column<DeviceStat>[] = [
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[13%]",
|
||||
accessor: (row) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "IP Address",
|
||||
className: "w-[12%]",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => setSelectedDevice(row)}
|
||||
className="flex items-center justify-center gap-1 group text-center"
|
||||
title="Click to view device details"
|
||||
>
|
||||
<span className="font-semibold text-blue-400 group-hover:text-blue-300 transition-colors font-mono text-sm">
|
||||
{row.ip_address || "-"}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||
</button>
|
||||
{row.device_label && (
|
||||
<span className="text-[10px] text-slate-400 font-medium mt-0.5 truncate max-w-[120px]" title={row.device_label}>
|
||||
{row.device_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "MAC Address",
|
||||
className: "w-[17%]",
|
||||
accessor: (row) => {
|
||||
const isRouted = row.is_gateway_routed === 1;
|
||||
const hasMan = row.manufacturer && row.manufacturer !== "-" && row.manufacturer !== "Unknown";
|
||||
const info = hasMan ? row.manufacturer : (row.device_type && row.device_type !== "-" && row.device_type !== "Generic Client" ? row.device_type : "");
|
||||
|
||||
return row.mac_address ? (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground w-fit font-mono">
|
||||
{row.mac_address}
|
||||
</code>
|
||||
{(isRouted || info) && (
|
||||
<span className={`text-[10px] font-sans font-medium ${isRouted ? "text-amber-500/90 italic" : "text-slate-400/80"}`}>
|
||||
{isRouted ? "Via Routed Gateway" : ""}
|
||||
{isRouted && info ? ` • ${info}` : (!isRouted ? info : "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Type",
|
||||
accessor: (row) => row.device_type || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: "OS",
|
||||
accessor: (row) => row.os_label || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: "Manufacturer",
|
||||
accessor: (row) => row.manufacturer || "-",
|
||||
className: "w-[14%]"
|
||||
},
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
];
|
||||
|
||||
const filteredData = all.filter((row: any) => {
|
||||
if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false;
|
||||
if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false;
|
||||
if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false;
|
||||
// Date filtering is handled server-side via date_from/date_to on timestamp.
|
||||
// last_seen (DPI network activity time) != timestamp (collection cycle time),
|
||||
// so client-side date filtering would incorrectly exclude valid records.
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
// Use the data retention window (7 days) as the minimum selectable date.
|
||||
// Do NOT compute minDate from loaded data — the loaded data is already filtered
|
||||
// by the sidebar time range (e.g., last 24h), which would incorrectly restrict
|
||||
// the calendar to only dates within the current filter window.
|
||||
const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Devices
|
||||
{devices.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Monitor and manage connected network devices.{" "}
|
||||
<span className="text-slate-500 text-xs">Click an IP to see app usage & flows.</span>
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="devices" />
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
}}
|
||||
showDateRange={true}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
minDate={minDate}
|
||||
onDateChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
<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-white">Active Devices ({processedData.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={processedData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search devices by IP, MAC..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesSearch = (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.mac_address || "").toLowerCase().includes(q) ||
|
||||
(row.os_label || "").toLowerCase().includes(q) ||
|
||||
(row.device_type || "").toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
return matchesSearch;
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `devices-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "Device Label", "MAC Address", "Device Type", "OS", "Manufacturer", "Download (bytes)", "Upload (bytes)", "Last Seen"],
|
||||
rowSerializer: (row) => [
|
||||
row.ip_address || "",
|
||||
row.device_label || "",
|
||||
row.mac_address || "",
|
||||
row.device_type || "",
|
||||
row.os_label || "",
|
||||
row.manufacturer || "",
|
||||
row.download ?? 0,
|
||||
row.upload ?? 0,
|
||||
row.last_seen ? new Date(row.last_seen).toISOString() : "",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device Detail Modal */}
|
||||
{selectedDevice && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip_address}
|
||||
deviceData={selectedDevice}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const devices = useDevices();
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
||||
|
||||
const {
|
||||
filters, dateFrom, dateTo,
|
||||
handleFilterChange, setDateFrom, setDateTo,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Devices | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const all = devices.data || [];
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "device_type", label: "Device Type", type: "select", value: filters.device_type, options: opts("device_type") },
|
||||
{ id: "os_label", label: "OS", type: "select", value: filters.os_label, options: opts("os_label") },
|
||||
{ id: "manufacturer", label: "Manufacturer", type: "select", value: filters.manufacturer, options: opts("manufacturer") },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const columns: Column<DeviceStat>[] = [
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[13%]",
|
||||
accessor: (row) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "IP Address",
|
||||
className: "w-[12%]",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => setSelectedDevice(row)}
|
||||
className="flex items-center justify-center gap-1 group text-center"
|
||||
title="Click to view device details"
|
||||
>
|
||||
<span className="font-semibold text-blue-400 group-hover:text-blue-300 transition-colors font-mono text-sm">
|
||||
{row.ip_address || "-"}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||
</button>
|
||||
{row.device_label && (
|
||||
<span className="text-[10px] text-slate-400 font-medium mt-0.5 truncate max-w-[120px]" title={row.device_label}>
|
||||
{row.device_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "MAC Address",
|
||||
className: "w-[17%]",
|
||||
accessor: (row) => {
|
||||
const isRouted = row.is_gateway_routed === 1;
|
||||
const hasMan = row.manufacturer && row.manufacturer !== "-" && row.manufacturer !== "Unknown";
|
||||
const info = hasMan ? row.manufacturer : (row.device_type && row.device_type !== "-" && row.device_type !== "Generic Client" ? row.device_type : "");
|
||||
|
||||
return row.mac_address ? (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground w-fit font-mono">
|
||||
{row.mac_address}
|
||||
</code>
|
||||
{(isRouted || info) && (
|
||||
<span className={`text-[10px] font-sans font-medium ${isRouted ? "text-amber-500/90 italic" : "text-slate-400/80"}`}>
|
||||
{isRouted ? "Via Routed Gateway" : ""}
|
||||
{isRouted && info ? ` • ${info}` : (!isRouted ? info : "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Type",
|
||||
accessor: (row) => row.device_type || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: "OS",
|
||||
accessor: (row) => row.os_label || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: "Manufacturer",
|
||||
accessor: (row) => row.manufacturer || "-",
|
||||
className: "w-[14%]"
|
||||
},
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
];
|
||||
|
||||
const filteredData = all.filter((row: any) => {
|
||||
if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false;
|
||||
if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false;
|
||||
if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false;
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : "";
|
||||
if (dateFrom && d < dateFrom) return false;
|
||||
if (dateTo && d > dateTo) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const minTime = all.reduce((min: number, r: any) => {
|
||||
if (r.last_seen) {
|
||||
const time = new Date(r.last_seen).getTime();
|
||||
if (isFinite(time) && time < min) return time;
|
||||
}
|
||||
return min;
|
||||
}, Infinity);
|
||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Devices
|
||||
{devices.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Monitor and manage connected network devices.{" "}
|
||||
<span className="text-slate-500 text-xs">Click an IP to see app usage & flows.</span>
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="devices" />
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
}}
|
||||
showDateRange={true}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
minDate={minDate}
|
||||
onDateChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
<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-white">Active Devices ({processedData.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={processedData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search devices by IP, MAC..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesSearch = (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.mac_address || "").toLowerCase().includes(q) ||
|
||||
(row.os_label || "").toLowerCase().includes(q) ||
|
||||
(row.device_type || "").toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
return matchesSearch;
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `devices-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "Device Label", "MAC Address", "Device Type", "OS", "Manufacturer", "Download (bytes)", "Upload (bytes)", "Last Seen"],
|
||||
rowSerializer: (row) => [
|
||||
row.ip_address || "",
|
||||
row.device_label || "",
|
||||
row.mac_address || "",
|
||||
row.device_type || "",
|
||||
row.os_label || "",
|
||||
row.manufacturer || "",
|
||||
row.download ?? 0,
|
||||
row.upload ?? 0,
|
||||
row.last_seen ? new Date(row.last_seen).toISOString() : "",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device Detail Modal */}
|
||||
{selectedDevice && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip_address}
|
||||
deviceData={selectedDevice}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export default function DNSPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "DNS | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -96,9 +97,9 @@ export default function DNSPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
DNS Intelligence
|
||||
DNS
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<HelpTrigger pageId="dns" />
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Tabs, Tab } from "@/components/ui/Tabs";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
|
||||
import { MetadataDetailType } from "@/lib/api-metadata-detail";
|
||||
|
||||
interface DpiAnalyticsTabsProps {
|
||||
sniHostnames: any[];
|
||||
loadingSni: boolean;
|
||||
sslServerCn: any[];
|
||||
loadingSslCn: boolean;
|
||||
quicHostnames: any[];
|
||||
loadingQuic: boolean;
|
||||
netbios: any[];
|
||||
loadingNetbios: boolean;
|
||||
mdnsHostnames: any[];
|
||||
loadingMdns: boolean;
|
||||
discoveryOs: any[];
|
||||
loadingOs: boolean;
|
||||
dhcpFingerprints: any[];
|
||||
loadingDhcp: boolean;
|
||||
httpUserAgents: any[];
|
||||
loadingHttpUa: boolean;
|
||||
sshVersions: any[];
|
||||
loadingSsh: boolean;
|
||||
bittorrentHashes: any[];
|
||||
loadingBittorrent: boolean;
|
||||
openDetail: (type: MetadataDetailType, value: string) => void;
|
||||
}
|
||||
|
||||
function ClickHint() {
|
||||
return (
|
||||
<span className="ml-2 text-[10px] font-normal text-blue-400/70 border border-blue-400/30 rounded px-1 py-0.5 align-middle">
|
||||
Click row for details
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DpiAnalyticsTabs({
|
||||
sniHostnames,
|
||||
loadingSni,
|
||||
sslServerCn,
|
||||
loadingSslCn,
|
||||
quicHostnames,
|
||||
loadingQuic,
|
||||
netbios,
|
||||
loadingNetbios,
|
||||
mdnsHostnames,
|
||||
loadingMdns,
|
||||
discoveryOs,
|
||||
loadingOs,
|
||||
dhcpFingerprints,
|
||||
loadingDhcp,
|
||||
httpUserAgents,
|
||||
loadingHttpUa,
|
||||
sshVersions,
|
||||
loadingSsh,
|
||||
bittorrentHashes,
|
||||
loadingBittorrent,
|
||||
openDetail,
|
||||
}: DpiAnalyticsTabsProps) {
|
||||
return (
|
||||
<Tabs>
|
||||
{/* ── Hostnames & Servers ──────────────────────────────────────────── */}
|
||||
<Tab label="Hostnames & Servers">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>SNI Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sniHostnames}
|
||||
isLoading={loadingSni}
|
||||
onRowClick={(row: any) => openDetail("sni_hostname", row.sni_hostname)}
|
||||
columns={[
|
||||
{
|
||||
header: "Hostname",
|
||||
accessor: (row: any) => {
|
||||
const srv = getKnownDomainService(row.sni_hostname);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
|
||||
<span className="truncate w-full block" title={row.sni_hostname}>{row.sni_hostname}</span>
|
||||
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>SSL Server Common Names<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sslServerCn}
|
||||
isLoading={loadingSslCn}
|
||||
onRowClick={(row: any) => openDetail("ssl_server_cn", row.ssl_server_cn)}
|
||||
columns={[
|
||||
{
|
||||
header: "Common Name",
|
||||
accessor: (row: any) => {
|
||||
const srv = getKnownDomainService(row.ssl_server_cn);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
|
||||
<span className="truncate w-full block" title={row.ssl_server_cn}>{row.ssl_server_cn}</span>
|
||||
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>QUIC Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={quicHostnames}
|
||||
isLoading={loadingQuic}
|
||||
onRowClick={(row: any) => openDetail("quic_hostname", row.quic_hostname)}
|
||||
columns={[
|
||||
{
|
||||
header: "Hostname",
|
||||
accessor: (row: any) => {
|
||||
const srv = getKnownDomainService(row.quic_hostname);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
|
||||
<span className="truncate w-full block" title={row.quic_hostname}>{row.quic_hostname}</span>
|
||||
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>NetBIOS Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={netbios}
|
||||
isLoading={loadingNetbios}
|
||||
onRowClick={(row: any) => openDetail("netbios_hostname", row.hostname)}
|
||||
columns={[{ header: "Hostname", accessor: (row: any) => row.hostname }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader><CardTitle>mDNS Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={mdnsHostnames}
|
||||
isLoading={loadingMdns}
|
||||
onRowClick={(row: any) => openDetail("mdns_hostname", row.mdns_hostname)}
|
||||
columns={[{ header: "Hostname", accessor: (row: any) => row.mdns_hostname }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
{/* ── Client Fingerprints ──────────────────────────────────────────── */}
|
||||
<Tab label="Client Fingerprints">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader><CardTitle>Operating Systems Discovery<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={discoveryOs}
|
||||
isLoading={loadingOs}
|
||||
onRowClick={(row: any) => openDetail("os_label", row.os_label)}
|
||||
columns={[
|
||||
{ header: "Operating System", accessor: (row: any) => row.os_label },
|
||||
{ header: "Download", accessor: (row: any) => fmtBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row: any) => fmtBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DHCP Fingerprints<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={dhcpFingerprints}
|
||||
isLoading={loadingDhcp}
|
||||
onRowClick={(row: any) => openDetail("dhcp_fingerprint", row.fingerprint)}
|
||||
columns={[{ header: "Fingerprint", accessor: (row: any) => row.fingerprint }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>HTTP User Agents<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={httpUserAgents}
|
||||
isLoading={loadingHttpUa}
|
||||
onRowClick={(row: any) => openDetail("http_useragent", row.user_agent)}
|
||||
columns={[{ header: "User Agent", accessor: (row: any) => row.user_agent }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
{/* ── Specific Protocols ───────────────────────────────────────────── */}
|
||||
<Tab label="Specific Protocols">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle>SSH Versions<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sshVersions}
|
||||
isLoading={loadingSsh}
|
||||
onRowClick={(row: any) => openDetail("ssh_version", row.ssh_version)}
|
||||
columns={[{ header: "SSH Version", accessor: (row: any) => row.ssh_version }, { header: "Total", accessor: (row: any) => fmtBytes(row.total) }]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>BitTorrent Hashes<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={bittorrentHashes}
|
||||
isLoading={loadingBittorrent}
|
||||
onRowClick={(row: any) => openDetail("bittorrent_hash", row.info_hash)}
|
||||
columns={[
|
||||
{ header: "Label", accessor: (row: any) => row.label },
|
||||
{ header: "Info Hash", accessor: (row: any) => row.info_hash },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Tabs, Tab } from "@/components/ui/Tabs";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
useNetbios, useDiscoveryOs, useDhcpFingerprints,
|
||||
useHttpUserAgents, useSniHostnames, useSslServerCn,
|
||||
@@ -13,24 +10,18 @@ import {
|
||||
import { MetadataDetailPanel } from "@/components/dpi/MetadataDetailPanel";
|
||||
import { MetadataDetailType } from "@/lib/api-metadata-detail";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
// Clickable hint badge shown in table column headers when rows are interactive
|
||||
function ClickHint() {
|
||||
return (
|
||||
<span className="ml-2 text-[10px] font-normal text-blue-400/70 border border-blue-400/30 rounded px-1 py-0.5 align-middle">
|
||||
Click row for details
|
||||
</span>
|
||||
);
|
||||
}
|
||||
import DpiAnalyticsTabs from "./DpiAnalyticsTabs";
|
||||
|
||||
export default function DpiAnalyticsPage() {
|
||||
const { timeRange } = useTimeFilter();
|
||||
// Detail panel state
|
||||
const [detailType, setDetailType] = useState<MetadataDetailType | null>(null);
|
||||
const [detailType, setDetailType] = useState<MetadataDetailType | null>(null);
|
||||
const [detailValue, setDetailValue] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "DPI MetaData | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
const openDetail = useCallback((type: MetadataDetailType, value: string) => {
|
||||
if (!value || value === "—") return;
|
||||
setDetailType(type);
|
||||
@@ -42,25 +33,21 @@ export default function DpiAnalyticsPage() {
|
||||
setDetailValue(null);
|
||||
}, []);
|
||||
|
||||
// Hostnames & Servers Tab
|
||||
const { data: sniHostnames, isLoading: loadingSni } = useSniHostnames(50);
|
||||
const { data: sslServerCn, isLoading: loadingSslCn } = useSslServerCn(50);
|
||||
const { data: quicHostnames, isLoading: loadingQuic } = useQuicHostnames(50);
|
||||
const { data: netbios, isLoading: loadingNetbios } = useNetbios(50);
|
||||
const { data: mdnsHostnames, isLoading: loadingMdns } = useMdnsHostnames(50);
|
||||
|
||||
// Client Fingerprints Tab
|
||||
const { data: discoveryOs, isLoading: loadingOs } = useDiscoveryOs(50);
|
||||
const { data: dhcpFingerprints, isLoading: loadingDhcp } = useDhcpFingerprints(50);
|
||||
const { data: httpUserAgents, isLoading: loadingHttpUa } = useHttpUserAgents(50);
|
||||
|
||||
// Specific Protocols Tab
|
||||
const { data: sshVersions, isLoading: loadingSsh } = useSshVersions(50);
|
||||
const { data: bittorrentHashes, isLoading: loadingBittorrent } = useBittorrentHashes(50);
|
||||
// Data Fetch Hooks
|
||||
const { data: sniHostnames, isLoading: loadingSni } = useSniHostnames();
|
||||
const { data: sslServerCn, isLoading: loadingSslCn } = useSslServerCn();
|
||||
const { data: quicHostnames, isLoading: loadingQuic } = useQuicHostnames();
|
||||
const { data: netbios, isLoading: loadingNetbios } = useNetbios();
|
||||
const { data: mdnsHostnames, isLoading: loadingMdns } = useMdnsHostnames();
|
||||
const { data: discoveryOs, isLoading: loadingOs } = useDiscoveryOs();
|
||||
const { data: dhcpFingerprints, isLoading: loadingDhcp } = useDhcpFingerprints();
|
||||
const { data: httpUserAgents, isLoading: loadingHttpUa } = useHttpUserAgents();
|
||||
const { data: sshVersions, isLoading: loadingSsh } = useSshVersions();
|
||||
const { data: bittorrentHashes, isLoading: loadingBittorrent } = useBittorrentHashes();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">DPI MetaData</h1>
|
||||
<p className="text-slate-400 mt-2">
|
||||
@@ -71,216 +58,30 @@ export default function DpiAnalyticsPage() {
|
||||
<HelpTrigger pageId="dpi-analytics" />
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
{/* ── Hostnames & Servers ──────────────────────────────────────────── */}
|
||||
<Tab label="Hostnames & Servers">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<DpiAnalyticsTabs
|
||||
sniHostnames={sniHostnames || []}
|
||||
loadingSni={loadingSni}
|
||||
sslServerCn={sslServerCn || []}
|
||||
loadingSslCn={loadingSslCn}
|
||||
quicHostnames={quicHostnames || []}
|
||||
loadingQuic={loadingQuic}
|
||||
netbios={netbios || []}
|
||||
loadingNetbios={loadingNetbios}
|
||||
mdnsHostnames={mdnsHostnames || []}
|
||||
loadingMdns={loadingMdns}
|
||||
discoveryOs={discoveryOs || []}
|
||||
loadingOs={loadingOs}
|
||||
dhcpFingerprints={dhcpFingerprints || []}
|
||||
loadingDhcp={loadingDhcp}
|
||||
httpUserAgents={httpUserAgents || []}
|
||||
loadingHttpUa={loadingHttpUa}
|
||||
sshVersions={sshVersions || []}
|
||||
loadingSsh={loadingSsh}
|
||||
bittorrentHashes={bittorrentHashes || []}
|
||||
loadingBittorrent={loadingBittorrent}
|
||||
openDetail={openDetail}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>SNI Hostnames (TLS)<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sniHostnames}
|
||||
isLoading={loadingSni}
|
||||
onRowClick={(row: any) => openDetail("sni_hostname", row.sni_hostname)}
|
||||
columns={[
|
||||
{
|
||||
header: "Hostname",
|
||||
accessor: (row: any) => {
|
||||
const srv = getKnownDomainService(row.sni_hostname);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
|
||||
<span className="truncate w-full block" title={row.sni_hostname}>{row.sni_hostname}</span>
|
||||
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>SSL Server Common Names<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sslServerCn}
|
||||
isLoading={loadingSslCn}
|
||||
onRowClick={(row: any) => openDetail("ssl_server_cn", row.ssl_server_cn)}
|
||||
columns={[
|
||||
{
|
||||
header: "Common Name",
|
||||
accessor: (row: any) => {
|
||||
const srv = getKnownDomainService(row.ssl_server_cn);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
|
||||
<span className="truncate w-full block" title={row.ssl_server_cn}>{row.ssl_server_cn}</span>
|
||||
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>QUIC Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={quicHostnames}
|
||||
isLoading={loadingQuic}
|
||||
onRowClick={(row: any) => openDetail("quic_hostname", row.quic_hostname)}
|
||||
columns={[
|
||||
{
|
||||
header: "Hostname",
|
||||
accessor: (row: any) => {
|
||||
const srv = getKnownDomainService(row.quic_hostname);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-1 w-full overflow-hidden text-center">
|
||||
<span className="truncate w-full block" title={row.quic_hostname}>{row.quic_hostname}</span>
|
||||
{srv && <span className="text-[10px] font-medium text-indigo-400 mt-0.5 truncate w-full block" title={srv}>{srv}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>NetBIOS Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={netbios}
|
||||
isLoading={loadingNetbios}
|
||||
onRowClick={(row: any) => openDetail("netbios_hostname", row.hostname)}
|
||||
columns={[
|
||||
{ header: "Hostname", accessor: (row: any) => row.hostname },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader><CardTitle>mDNS Hostnames<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={mdnsHostnames}
|
||||
isLoading={loadingMdns}
|
||||
onRowClick={(row: any) => openDetail("mdns_hostname", row.mdns_hostname)}
|
||||
columns={[
|
||||
{ header: "Hostname", accessor: (row: any) => row.mdns_hostname },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
{/* ── Client Fingerprints ──────────────────────────────────────────── */}
|
||||
<Tab label="Client Fingerprints">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
|
||||
<Card className="xl:col-span-2">
|
||||
<CardHeader><CardTitle>Operating Systems Discovery<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={discoveryOs}
|
||||
isLoading={loadingOs}
|
||||
onRowClick={(row: any) => openDetail("os_label", row.os_label)}
|
||||
columns={[
|
||||
{ header: "Operating System", accessor: (row: any) => row.os_label },
|
||||
{ header: "Download", accessor: (row: any) => fmtBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row: any) => fmtBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>DHCP Fingerprints<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={dhcpFingerprints}
|
||||
isLoading={loadingDhcp}
|
||||
onRowClick={(row: any) => openDetail("dhcp_fingerprint", row.fingerprint)}
|
||||
columns={[
|
||||
{ header: "Fingerprint", accessor: (row: any) => row.fingerprint },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>HTTP User Agents<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={httpUserAgents}
|
||||
isLoading={loadingHttpUa}
|
||||
onRowClick={(row: any) => openDetail("http_useragent", row.user_agent)}
|
||||
columns={[
|
||||
{ header: "User Agent", accessor: (row: any) => row.user_agent },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
{/* ── Specific Protocols ───────────────────────────────────────────── */}
|
||||
<Tab label="Specific Protocols">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>SSH Versions<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sshVersions}
|
||||
isLoading={loadingSsh}
|
||||
onRowClick={(row: any) => openDetail("ssh_version", row.ssh_version)}
|
||||
columns={[
|
||||
{ header: "SSH Version", accessor: (row: any) => row.ssh_version },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>BitTorrent Hashes<ClickHint /></CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={bittorrentHashes}
|
||||
isLoading={loadingBittorrent}
|
||||
onRowClick={(row: any) => openDetail("bittorrent_hash", row.info_hash)}
|
||||
columns={[
|
||||
{ header: "Label", accessor: (row: any) => row.label },
|
||||
{ header: "Info Hash", accessor: (row: any) => row.info_hash },
|
||||
{ header: "Total", accessor: (row: any) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{/* ── Row Detail Slide-Out Panel ────────────────────────────────────── */}
|
||||
<MetadataDetailPanel
|
||||
type={detailType}
|
||||
value={detailValue}
|
||||
|
||||
@@ -19,6 +19,7 @@ function EventsContent() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
const [selectedMac, setSelectedMac] = useState<string | null>(null);
|
||||
const { data, isLoading } = useEvents();
|
||||
const searchParams = useSearchParams();
|
||||
const highlightId = searchParams.get("highlight");
|
||||
|
||||
@@ -28,9 +29,6 @@ function EventsContent() {
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
// Pass dateFrom/dateTo so the backend filters by the exact calendar date range
|
||||
const { data, isLoading } = useEvents(0, dateFrom, dateTo);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
@@ -65,10 +63,14 @@ function EventsContent() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Use the data retention window (7 days) as the minimum selectable date.
|
||||
// Dynamic computation from loaded data causes a circular dependency where
|
||||
// the sidebar time filter (e.g., Last 24h) restricts the date picker range.
|
||||
const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const minTime = all.reduce((min: number, r: any) => {
|
||||
if (r.timestamp) {
|
||||
const time = new Date(r.timestamp).getTime();
|
||||
if (isFinite(time) && time < min) return time;
|
||||
}
|
||||
return min;
|
||||
}, Infinity);
|
||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
||||
|
||||
const getSeverityIcon = (severity: string) => {
|
||||
switch (severity.toLowerCase()) {
|
||||
@@ -84,11 +86,11 @@ function EventsContent() {
|
||||
};
|
||||
|
||||
const columns: Column<typeof data[0]>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%] text-center" },
|
||||
{
|
||||
header: "Timestamp (WIB)",
|
||||
accessor: (row) => <TimestampCell timestamp={row.timestamp} showActiveStatus={false} />,
|
||||
className: "w-[180px] min-w-[180px] max-w-[180px]"
|
||||
className: "w-[15%] text-center"
|
||||
},
|
||||
{
|
||||
header: "Severity",
|
||||
@@ -98,32 +100,32 @@ function EventsContent() {
|
||||
<span className="capitalize">{row.severity}</span>
|
||||
</div>
|
||||
),
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px]"
|
||||
className: "w-[10%] text-center"
|
||||
},
|
||||
{ header: "Event Type", accessor: (row) => row.event_type, className: "w-[130px] min-w-[130px] max-w-[130px]" },
|
||||
{ header: "Event Type", accessor: (row) => row.event_type, className: "w-[12%] text-center" },
|
||||
{
|
||||
header: "MAC Address",
|
||||
accessor: (row) => row.mac_address ? (
|
||||
<button
|
||||
onClick={() => setSelectedMac(row.mac_address)}
|
||||
className="font-mono text-xs text-emerald-400 hover:text-emerald-300 hover:underline text-center w-full focus:outline-none"
|
||||
className="font-mono text-xs text-emerald-400 hover:text-emerald-300 hover:underline text-center w-full focus:outline-none truncate"
|
||||
>
|
||||
{row.mac_address}
|
||||
</button>
|
||||
) : <span className="text-slate-500">-</span>,
|
||||
className: "w-[160px] min-w-[160px] max-w-[160px]"
|
||||
className: "w-[14%] text-center"
|
||||
},
|
||||
{
|
||||
header: "Source IP",
|
||||
accessor: (row) => row.source_ip ? (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.source_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs text-center w-full focus:outline-none"
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs text-center w-full focus:outline-none truncate"
|
||||
>
|
||||
{row.source_ip}
|
||||
</button>
|
||||
) : <span className="text-slate-500">-</span>,
|
||||
className: "w-[140px] min-w-[140px] max-w-[140px]"
|
||||
className: "w-[12%] text-center"
|
||||
},
|
||||
{
|
||||
header: "Message",
|
||||
@@ -150,13 +152,13 @@ function EventsContent() {
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: "text-center w-full min-w-0 max-w-full"
|
||||
className: "w-[33%] text-center"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Network Events
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
@@ -223,3 +225,4 @@ export default function EventsPage() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
export { EventsContent };
|
||||
@@ -0,0 +1,46 @@
|
||||
export function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
|
||||
const label = appLabel || domain || "";
|
||||
const portStr = String(port);
|
||||
|
||||
const matches: Record<string, string> = {
|
||||
"1433": "MSSQL Database Server",
|
||||
"1434": "MSSQL Monitor Server",
|
||||
"3306": "MySQL/MariaDB Database Server",
|
||||
"5432": "PostgreSQL Database Server",
|
||||
"1521": "Oracle Database Server",
|
||||
"27017": "Database Server",
|
||||
"6379": "Redis Key-Value Cache",
|
||||
"80": "Unencrypted Web Traffic (HTTP)",
|
||||
"443": "Encrypted Web Traffic (HTTPS/TLS)",
|
||||
"22": "SSH Remote Management / SFTP",
|
||||
"21": "FTP File Upload/Download (Unencrypted)",
|
||||
"23": "Telnet Command Shell (Insecure)",
|
||||
"25": "SMTP Mail Delivery",
|
||||
"587": "Secure SMTP Mail Delivery",
|
||||
"110": "POP3 Mail Storage Retrieval",
|
||||
"993": "Secure IMAP Mail Service",
|
||||
"53": "DNS Server Query (Domain Lookup)",
|
||||
"123": "NTP Network Clock Synchronizer",
|
||||
"161": "SNMP Network Device Monitoring",
|
||||
"3389": "RDP Remote Desktop Management",
|
||||
"445": "SMB Windows File Sharing",
|
||||
"137": "NetBIOS Name Service",
|
||||
"138": "NetBIOS Datagram Service",
|
||||
"139": "NetBIOS Session Service",
|
||||
"1812": "RADIUS Authentication Server",
|
||||
"1813": "RADIUS Accounting Server"
|
||||
};
|
||||
|
||||
let mainLabel = label;
|
||||
let subLabel = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : "");
|
||||
|
||||
if (appLabel && appLabel.includes("(") && domain && domain.startsWith("Port ")) {
|
||||
mainLabel = appLabel;
|
||||
subLabel = domain;
|
||||
}
|
||||
|
||||
return {
|
||||
main: mainLabel || `Port ${port}`,
|
||||
sub: subLabel
|
||||
};
|
||||
}
|
||||
+128
-142
@@ -2,89 +2,66 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { useFlows } from "@/lib/api-with-context";
|
||||
import { useFlows, useFlowsOptions } from "@/lib/api-with-context";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, Download, Search } from "lucide-react";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||
import { explainAppOrPort } from "./explainFlow";
|
||||
import { downloadCsv } from "@/components/ui/DataTableCsvHelper";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
|
||||
const label = appLabel || domain || "";
|
||||
const portStr = String(port);
|
||||
|
||||
const matches: Record<string, string> = {
|
||||
"1433": "MSSQL Database Server",
|
||||
"1434": "MSSQL Monitor Server",
|
||||
"3306": "MySQL/MariaDB Database Server",
|
||||
"5432": "PostgreSQL Database Server",
|
||||
"1521": "Oracle Database Server",
|
||||
"27017": "Database Server",
|
||||
"6379": "Redis Key-Value Cache",
|
||||
"80": "Unencrypted Web Traffic (HTTP)",
|
||||
"443": "Encrypted Web Traffic (HTTPS/TLS)",
|
||||
"22": "SSH Remote Management / SFTP",
|
||||
"21": "FTP File Upload/Download (Unencrypted)",
|
||||
"23": "Telnet Command Shell (Insecure)",
|
||||
"25": "SMTP Mail Delivery",
|
||||
"587": "Secure SMTP Mail Delivery",
|
||||
"110": "POP3 Mail Storage Retrieval",
|
||||
"993": "Secure IMAP Mail Service",
|
||||
"53": "DNS Server Query (Domain Lookup)",
|
||||
"123": "NTP Network Clock Synchronizer",
|
||||
"161": "SNMP Network Device Monitoring",
|
||||
"3389": "RDP Remote Desktop Management",
|
||||
"445": "SMB Windows File Sharing",
|
||||
"137": "NetBIOS Name Service",
|
||||
"138": "NetBIOS Datagram Service",
|
||||
"139": "NetBIOS Session Service",
|
||||
"1812": "RADIUS Authentication Server",
|
||||
"1813": "RADIUS Accounting Server"
|
||||
};
|
||||
|
||||
let mainLabel = label;
|
||||
let subLabel = matches[portStr] || (label.toLowerCase().includes("tls") || label.toLowerCase().includes("https") ? "Encrypted Connection (SSL/TLS)" : "");
|
||||
|
||||
if (appLabel && appLabel.includes("(") && domain && domain.startsWith("Port ")) {
|
||||
mainLabel = appLabel;
|
||||
subLabel = domain;
|
||||
}
|
||||
|
||||
return {
|
||||
main: mainLabel || `Port ${port}`,
|
||||
sub: subLabel
|
||||
};
|
||||
}
|
||||
|
||||
export default function FlowsPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [ipSearch, setIpSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
const {
|
||||
filters, dateFrom, dateTo,
|
||||
handleFilterChange, setDateFrom, setDateTo,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
// Pass dateFrom/dateTo so the backend filters by the exact calendar date range
|
||||
const { data, isLoading } = useFlows(5000, dateFrom, dateTo);
|
||||
// Debounce search query to prevent database query spamming
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(ipSearch);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [ipSearch]);
|
||||
|
||||
// Reset page to 1 when filters, date picker, or search text changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [filters, dateFrom, dateTo, debouncedSearch]);
|
||||
|
||||
const { data: optionsData } = useFlowsOptions();
|
||||
const { data: flows = [], total = 0, isLoading } = useFlows(currentPage, 50, {
|
||||
...filters,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
search: debouncedSearch
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Flows | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const all = data || [];
|
||||
|
||||
|
||||
// Extract unique options
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
if (!optionsData) return [];
|
||||
const mapping: Record<string, string[]> = {
|
||||
protocol: optionsData.protocols, src_ip: optionsData.srcIps, dst_ip: optionsData.dstIps,
|
||||
dst_port: optionsData.dstPorts, app_label: optionsData.apps, domain: optionsData.domains
|
||||
};
|
||||
return mapping[key] || [];
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
@@ -98,42 +75,15 @@ export default function FlowsPage() {
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const filteredData = all.filter((row: any) => {
|
||||
if (filters.protocol && filters.protocol !== "All" && row.protocol !== filters.protocol) return false;
|
||||
if (filters.src_ip && filters.src_ip !== "All" && row.src_ip !== filters.src_ip) return false;
|
||||
if (filters.dst_ip && filters.dst_ip !== "All" && row.dst_ip !== filters.dst_ip) return false;
|
||||
if (filters.dst_port && filters.dst_port !== "All" && String(row.dst_port) !== filters.dst_port) return false;
|
||||
if (filters.app && filters.app !== "All" && row.app_label !== filters.app) return false;
|
||||
if (filters.domain && filters.domain !== "All" && row.domain !== filters.domain) return false;
|
||||
// Date filtering is handled server-side via date_from/date_to on timestamp.
|
||||
// last_seen != timestamp (a flow collected July 13 can have last_seen=July 14)
|
||||
// so client-side filtering by last_seen would incorrectly exclude valid records.
|
||||
return true;
|
||||
});
|
||||
const minDate = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const handleExport = () => {
|
||||
const headers = ["Flow ID", "Src IP", "Dst IP", "Dst Port", "Protocol", "App / Domain", "Download (bytes)", "Upload (bytes)"];
|
||||
const rows = [headers, ...flows.map(r => [r.flow_id || "", r.src_ip || "", r.dst_ip || "", r.dst_port ?? "", r.protocol || "", r.app_label || r.domain || `Port ${r.dst_port}`, r.bytes_download ?? 0, r.bytes_upload ?? 0])];
|
||||
downloadCsv(`flows-${new Date().toISOString().slice(0,10)}.csv`, rows);
|
||||
};
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? (b.bytes_upload ?? 0) - (a.bytes_upload ?? 0) : (a.bytes_upload ?? 0) - (b.bytes_upload ?? 0));
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? (b.bytes_download ?? 0) - (a.bytes_download ?? 0) : (a.bytes_download ?? 0) - (b.bytes_download ?? 0));
|
||||
} else {
|
||||
processedData.sort((a, b) => {
|
||||
const timeA = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||||
const timeB = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
}
|
||||
|
||||
// Use the data retention window (7 days) as the minimum selectable date.
|
||||
// Dynamic computation from loaded data causes a circular dependency where
|
||||
// the sidebar time filter (e.g., Last 24h) restricts the date picker range.
|
||||
const minDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const columns: Column<typeof data[0]>[] = [
|
||||
const columns: Column<any>[] = [
|
||||
{
|
||||
header: "Last Seen",
|
||||
accessor: (row) => (
|
||||
@@ -143,70 +93,107 @@ export default function FlowsPage() {
|
||||
showActiveStatus={true}
|
||||
/>
|
||||
),
|
||||
className: "w-[120px]"
|
||||
className: "w-[14%] text-center",
|
||||
tooltip: (row) => new Date(row.last_seen).toLocaleString()
|
||||
},
|
||||
{
|
||||
header: "#",
|
||||
accessor: (row, i) => i + 1,
|
||||
className: "w-[4%] text-center"
|
||||
},
|
||||
{
|
||||
header: "Flow ID",
|
||||
accessor: (row) => row.flow_id,
|
||||
className: "w-[11%] text-center font-mono text-xs truncate"
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{ header: "Flow ID", accessor: (row) => row.flow_id },
|
||||
{
|
||||
header: "Src IP",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.src_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-left focus:outline-none"
|
||||
>
|
||||
{row.src_ip}
|
||||
</button>
|
||||
)
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.src_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full"
|
||||
>
|
||||
{row.src_ip}
|
||||
</button>
|
||||
{row.src_label && (
|
||||
<span className="text-[10px] text-cyan-400 font-semibold tracking-wide mt-0.5 truncate w-full block">
|
||||
{row.src_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
className: "w-[11%] text-center",
|
||||
tooltip: (row) => row.src_label ? `${row.src_ip} (${row.src_label})` : row.src_ip
|
||||
},
|
||||
{
|
||||
header: "Dst IP",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.dst_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-left focus:outline-none"
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full"
|
||||
>
|
||||
{row.dst_ip}
|
||||
</button>
|
||||
<IpDetails ip={row.dst_ip} />
|
||||
</div>
|
||||
)
|
||||
),
|
||||
className: "w-[13%] text-center",
|
||||
tooltip: (row) => row.dst_ip
|
||||
},
|
||||
{
|
||||
header: "Dst Port",
|
||||
accessor: (row) => row.dst_port,
|
||||
className: "w-[7%] text-center font-mono text-xs"
|
||||
},
|
||||
{
|
||||
header: "Protocol",
|
||||
accessor: (row) => row.protocol,
|
||||
className: "w-[7%] text-center text-xs"
|
||||
},
|
||||
{ header: "Dst Port", accessor: (row) => row.dst_port },
|
||||
{ header: "Protocol", accessor: (row) => row.protocol },
|
||||
{
|
||||
header: "App / Domain",
|
||||
accessor: (row) => {
|
||||
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-white truncate max-w-[200px]" title={exp.main}>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="font-semibold text-white truncate max-w-[200px] text-center" title={exp.main}>
|
||||
{exp.main}
|
||||
</span>
|
||||
{exp.sub && (
|
||||
<span className="text-[10px] text-slate-400 italic">
|
||||
<span className="text-[10px] text-slate-400 italic text-center truncate max-w-[200px]">
|
||||
{exp.sub}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: "w-[18%] text-center",
|
||||
tooltip: (row) => {
|
||||
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
|
||||
return exp.sub ? `${exp.main} (${exp.sub})` : exp.main;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>
|
||||
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>,
|
||||
className: "w-[8%] text-center",
|
||||
tooltip: (row) => `${fmtBytes(row.bytes_download)} (${(row.bytes_download || 0).toLocaleString()} bytes)`
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span>
|
||||
accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span>,
|
||||
className: "w-[7%] text-center",
|
||||
tooltip: (row) => `${fmtBytes(row.bytes_upload)} (${(row.bytes_upload || 0).toLocaleString()} bytes)`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Active Flows
|
||||
Flows
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<HelpTrigger pageId="flows" />
|
||||
@@ -235,38 +222,37 @@ export default function FlowsPage() {
|
||||
/>
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Active Flows ({processedData.length})</CardTitle>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4 gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<CardTitle className="text-white">Active Flows ({total.toLocaleString()})</CardTitle>
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search IP (source or dest)..."
|
||||
value={ipSearch}
|
||||
onChange={(e) => setIpSearch(e.target.value)}
|
||||
className="w-[240px] rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-1.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
title="Export current page flows to CSV"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/70 hover:text-emerald-300 transition-all shadow-sm whitespace-nowrap"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export CSV ({total.toLocaleString()})
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={processedData}
|
||||
data={flows}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search flows by IP, App, or Domain..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
(row.src_ip || "").toLowerCase().includes(q) ||
|
||||
(row.dst_ip || "").toLowerCase().includes(q) ||
|
||||
(row.app_label || "").toLowerCase().includes(q) ||
|
||||
(row.domain || "").toLowerCase().includes(q) ||
|
||||
(row.protocol || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `flows-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["Flow ID", "Src IP", "Dst IP", "Dst Port", "Protocol", "App / Domain", "Download (bytes)", "Upload (bytes)"],
|
||||
rowSerializer: (row) => [
|
||||
row.flow_id || "",
|
||||
row.src_ip || "",
|
||||
row.dst_ip || "",
|
||||
row.dst_port ?? "",
|
||||
row.protocol || "",
|
||||
row.app_label || row.domain || `Port ${row.dst_port}`,
|
||||
row.bytes_download ?? 0,
|
||||
row.bytes_upload ?? 0,
|
||||
],
|
||||
}}
|
||||
serverSide={true}
|
||||
totalCount={total}
|
||||
currentPage={currentPage}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
export default function GeographyPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const { data, isLoading } = useCountries(50); // fetch 50 countries
|
||||
const { data, isLoading } = useCountries();
|
||||
|
||||
const {
|
||||
filters,
|
||||
@@ -34,6 +34,7 @@ export default function GeographyPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Geo Traffic | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
@@ -103,10 +104,10 @@ export default function GeographyPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Geographic Traffic
|
||||
Geo Traffic
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Visualize data flow and activity across the globe.</p>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface DeviceDiscoveryTableProps {
|
||||
data: any[];
|
||||
isLoading: boolean;
|
||||
setSelectedDevice: (device: { ip: string; mac?: string } | null) => void;
|
||||
}
|
||||
|
||||
export function DeviceDiscoveryTable({ data, isLoading, setSelectedDevice }: DeviceDiscoveryTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
data={data} isLoading={isLoading}
|
||||
searchPlaceholder="Search by IP, MAC, OS..."
|
||||
searchFilter={(row: any, q: string) => {
|
||||
const query = q.toLowerCase();
|
||||
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query) || (row.device_type || '').toLowerCase().includes(query);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `device-discovery-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "MAC Address", "Device Type", "OS", "Manufacturer", "Download", "Upload", "Last Seen"],
|
||||
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.device_type || "", row.os_label || "", row.manufacturer || "", row.download || 0, row.upload || 0, row.last_seen || ""]
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[15%]",
|
||||
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "IP Address",
|
||||
className: "w-[16%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
|
||||
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
title={row.ip_address ? "Click to view device details" : ""}
|
||||
>
|
||||
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
|
||||
{row.ip_address || "Unknown IP"}
|
||||
</span>
|
||||
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ header: "Device Profile", className: "w-[20%]", accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<span className="text-white font-medium">{row.device_type || "-"}</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium mt-0.5">{row.os_label || "-"}</span>
|
||||
</div>
|
||||
) },
|
||||
{ header: "Manufacturer", className: "w-[20%]", accessor: (row: any) => row.manufacturer || "-" },
|
||||
{ header: "Bandwidth", className: "w-[25%]", accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center gap-0.5">
|
||||
<span className="text-cyan-400 text-xs font-mono">↓ {fmtBytes(row.download)}</span>
|
||||
<span className="text-green-400 text-xs font-mono">↑ {fmtBytes(row.upload)}</span>
|
||||
</div>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface EncryptionAuditTableProps {
|
||||
data: any[];
|
||||
isLoading: boolean;
|
||||
setSelectedDevice: (device: { ip: string; mac?: string } | null) => void;
|
||||
}
|
||||
|
||||
export function EncryptionAuditTable({ data, isLoading, setSelectedDevice }: EncryptionAuditTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
data={data} isLoading={isLoading}
|
||||
searchPlaceholder="Search by Device or IP..."
|
||||
searchFilter={(row: any, q: string) => {
|
||||
const query = q.toLowerCase();
|
||||
return (row.ip_address || '').toLowerCase().includes(query) || (row.device_label || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `encryption-audit-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["Device Label", "MAC Address", "IP Address", "Encrypted Data", "Encrypted %", "Total Data", "Risk Level"],
|
||||
rowSerializer: (row: any) => [row.device_label || "", row.mac_address || "", row.ip_address || "", row.encrypted || 0, row.encrypted_pct || 0, row.total || 0, row.risk_level || ""]
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[15%]",
|
||||
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "Device",
|
||||
className: "w-[25%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<span className="font-medium text-white">{row.device_label || row.ip_address}</span>
|
||||
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "IP Address",
|
||||
className: "w-[20%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
|
||||
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
title={row.ip_address ? "Click to view device details" : ""}
|
||||
>
|
||||
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
|
||||
{row.ip_address || "Unknown IP"}
|
||||
</span>
|
||||
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
|
||||
</button>
|
||||
{row.ip_address && <div className="mt-1 scale-90 origin-top"><IpDetails ip={row.ip_address} /></div>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Encrypted Data",
|
||||
className: "w-[26%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<span className="text-green-400 font-mono text-sm">{fmtBytes(row.encrypted)} ({row.encrypted_pct}%)</span>
|
||||
<span className="text-[10px] text-muted-foreground font-medium mt-0.5 uppercase tracking-wider">Total: {fmtBytes(row.total)}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Risk Level",
|
||||
className: "w-[10%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
row.risk_level === 'medium' ? 'bg-yellow-500/20 text-yellow-500' : 'bg-green-500/20 text-green-500'
|
||||
}`}>
|
||||
{row.risk_level === 'medium' ? 'Medium' : 'Safe'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { EncryptionAuditTable } from "./EncryptionAuditTable";
|
||||
import { DeviceDiscoveryTable } from "./DeviceDiscoveryTable";
|
||||
import { ServerDiscoveryTable } from "./ServerDiscoveryTable";
|
||||
|
||||
interface IntelligenceTablesProps {
|
||||
activeTab: string;
|
||||
cryptoMining: any[];
|
||||
loadingCrypto: boolean;
|
||||
torDetection: any[];
|
||||
loadingTor: boolean;
|
||||
vpnDetection: any[];
|
||||
loadingVpn: boolean;
|
||||
ipReputation: any[];
|
||||
loadingIpRep: boolean;
|
||||
insecureProtocols: any[];
|
||||
loadingInsecure: boolean;
|
||||
unencryptedPasswords: any[];
|
||||
loadingPasswords: boolean;
|
||||
encryptionAudit: any[];
|
||||
loadingEncryption: boolean;
|
||||
deviceDiscovery: any[];
|
||||
loadingDevice: boolean;
|
||||
serverDiscovery: any[];
|
||||
loadingServer: boolean;
|
||||
setSelectedDevice: (device: { ip: string; mac?: string } | null) => void;
|
||||
}
|
||||
|
||||
export function IntelligenceTables({
|
||||
activeTab,
|
||||
cryptoMining, loadingCrypto,
|
||||
torDetection, loadingTor,
|
||||
vpnDetection, loadingVpn,
|
||||
ipReputation, loadingIpRep,
|
||||
insecureProtocols, loadingInsecure,
|
||||
unencryptedPasswords, loadingPasswords,
|
||||
encryptionAudit, loadingEncryption,
|
||||
deviceDiscovery, loadingDevice,
|
||||
serverDiscovery, loadingServer,
|
||||
setSelectedDevice
|
||||
}: IntelligenceTablesProps) {
|
||||
switch (activeTab) {
|
||||
case "Crypto Mining":
|
||||
return (
|
||||
<DataTable
|
||||
data={cryptoMining} isLoading={loadingCrypto}
|
||||
columns={[
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "MAC Address", accessor: (row: any) => row.mac_address },
|
||||
{ header: "Pool Host", accessor: (row: any) => row.pool_host },
|
||||
{ header: "App", accessor: (row: any) => row.app_label },
|
||||
{ header: "Confidence", accessor: (row: any) => row.confidence },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Tor Detection":
|
||||
return (
|
||||
<DataTable
|
||||
data={torDetection} isLoading={loadingTor}
|
||||
columns={[
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "Exit Node", accessor: (row: any) => row.exit_node },
|
||||
{ header: "Country", accessor: (row: any) => row.country },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "VPN Detection":
|
||||
return (
|
||||
<DataTable
|
||||
data={vpnDetection} isLoading={loadingVpn}
|
||||
columns={[
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "VPN Type", accessor: (row: any) => row.vpn_type },
|
||||
{
|
||||
header: "Remote IP",
|
||||
accessor: (row: any) => row.remote_ip ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{row.remote_ip}</span>
|
||||
<IpDetails ip={row.remote_ip} />
|
||||
</div>
|
||||
) : "—"
|
||||
},
|
||||
{ header: "Country", accessor: (row: any) => row.country },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "IP Reputation":
|
||||
return (
|
||||
<DataTable
|
||||
data={ipReputation} isLoading={loadingIpRep}
|
||||
columns={[
|
||||
{
|
||||
header: "IP Address",
|
||||
accessor: (row: any) => row.ip_address ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{row.ip_address}</span>
|
||||
<IpDetails ip={row.ip_address} />
|
||||
</div>
|
||||
) : "—"
|
||||
},
|
||||
{ header: "Reputation", accessor: (row: any) => row.reputation },
|
||||
{ header: "App", accessor: (row: any) => row.app_label },
|
||||
{ header: "Country", accessor: (row: any) => row.country },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Insecure Protocols":
|
||||
return (
|
||||
<DataTable
|
||||
data={insecureProtocols} isLoading={loadingInsecure}
|
||||
columns={[
|
||||
{ header: "Protocol", accessor: (row: any) => row.protocol },
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "Risk", accessor: (row: any) => row.risk },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Unencrypted Passwords":
|
||||
return (
|
||||
<DataTable
|
||||
data={unencryptedPasswords} isLoading={loadingPasswords}
|
||||
columns={[
|
||||
{ header: "Protocol", accessor: (row: any) => row.protocol },
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "Severity", accessor: (row: any) => row.severity },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Encryption Audit":
|
||||
return (
|
||||
<EncryptionAuditTable
|
||||
data={encryptionAudit} isLoading={loadingEncryption}
|
||||
setSelectedDevice={setSelectedDevice}
|
||||
/>
|
||||
);
|
||||
case "Device Discovery":
|
||||
return (
|
||||
<DeviceDiscoveryTable
|
||||
data={deviceDiscovery} isLoading={loadingDevice}
|
||||
setSelectedDevice={setSelectedDevice}
|
||||
/>
|
||||
);
|
||||
case "Server Discovery":
|
||||
return (
|
||||
<ServerDiscoveryTable
|
||||
data={serverDiscovery} isLoading={loadingServer}
|
||||
setSelectedDevice={setSelectedDevice}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface ServerDiscoveryTableProps {
|
||||
data: any[];
|
||||
isLoading: boolean;
|
||||
setSelectedDevice: (device: { ip: string; mac?: string } | null) => void;
|
||||
}
|
||||
|
||||
export function ServerDiscoveryTable({ data, isLoading, setSelectedDevice }: ServerDiscoveryTableProps) {
|
||||
return (
|
||||
<DataTable
|
||||
data={data} isLoading={isLoading}
|
||||
searchPlaceholder="Search by IP, MAC, Type..."
|
||||
searchFilter={(row: any, q: string) => {
|
||||
const query = q.toLowerCase();
|
||||
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.server_type || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `server-discovery-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "MAC Address", "Server Type", "Port", "OS"],
|
||||
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.server_type || "", row.port || "", row.os_label || ""]
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[15%]",
|
||||
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "Server IP",
|
||||
className: "w-[25%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
|
||||
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
title={row.ip_address ? "Click to view device details" : ""}
|
||||
>
|
||||
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
|
||||
{row.ip_address || "Unknown IP"}
|
||||
</span>
|
||||
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ header: "Server Type", className: "w-[28%]", accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<span className="text-white font-medium">{row.server_type}</span>
|
||||
{row.port > 0 && <span className="text-[10px] text-purple-400 font-mono font-medium mt-0.5">Port {row.port}</span>}
|
||||
</div>
|
||||
) },
|
||||
{ header: "OS / Platform", className: "w-[28%]", accessor: (row: any) => row.os_label },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,13 +8,10 @@ import {
|
||||
useIntelEncryptionAudit, useIntelDeviceDiscovery, useIntelServerDiscovery
|
||||
} from "@/lib/api-advanced";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Network, ShieldAlert, ChevronRight } from "lucide-react";
|
||||
import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Network, ShieldAlert } from "lucide-react";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import { IntelligenceTables } from "./IntelligenceTables";
|
||||
|
||||
export default function IntelligencePage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -22,19 +19,20 @@ export default function IntelligencePage() {
|
||||
const [activeTab, setActiveTab] = useState<string>("Crypto Mining");
|
||||
const [selectedDevice, setSelectedDevice] = useState<{ip: string, mac?: string} | null>(null);
|
||||
|
||||
// Advanced data hooks
|
||||
const { data: cryptoMining, isLoading: loadingCrypto } = useIntelCryptoMining(20);
|
||||
const { data: torDetection, isLoading: loadingTor } = useIntelTorDetection(20);
|
||||
const { data: vpnDetection, isLoading: loadingVpn } = useIntelVpnDetection(20);
|
||||
const { data: ipReputation, isLoading: loadingIpRep } = useIntelIpReputation(20);
|
||||
const { data: insecureProtocols, isLoading: loadingInsecure } = useIntelInsecureProtocols(20);
|
||||
const { data: unencryptedPasswords, isLoading: loadingPasswords } = useIntelUnencryptedPasswords(20);
|
||||
const { data: encryptionAudit, isLoading: loadingEncryption } = useIntelEncryptionAudit(20);
|
||||
const { data: deviceDiscovery, isLoading: loadingDevice } = useIntelDeviceDiscovery(20);
|
||||
const { data: serverDiscovery, isLoading: loadingServer } = useIntelServerDiscovery(20);
|
||||
// Advanced data hooks (no hardcoded limits, default to 1,000,000)
|
||||
const { data: cryptoMining = [], isLoading: loadingCrypto } = useIntelCryptoMining();
|
||||
const { data: torDetection = [], isLoading: loadingTor } = useIntelTorDetection();
|
||||
const { data: vpnDetection = [], isLoading: loadingVpn } = useIntelVpnDetection();
|
||||
const { data: ipReputation = [], isLoading: loadingIpRep } = useIntelIpReputation();
|
||||
const { data: insecureProtocols = [], isLoading: loadingInsecure } = useIntelInsecureProtocols();
|
||||
const { data: unencryptedPasswords = [], isLoading: loadingPasswords } = useIntelUnencryptedPasswords();
|
||||
const { data: encryptionAudit = [], isLoading: loadingEncryption } = useIntelEncryptionAudit();
|
||||
const { data: deviceDiscovery = [], isLoading: loadingDevice } = useIntelDeviceDiscovery();
|
||||
const { data: serverDiscovery = [], isLoading: loadingServer } = useIntelServerDiscovery();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Threat Intelligence | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
@@ -96,289 +94,16 @@ export default function IntelligencePage() {
|
||||
}
|
||||
];
|
||||
|
||||
const renderActiveTable = () => {
|
||||
switch(activeTab) {
|
||||
case "Crypto Mining":
|
||||
return (
|
||||
<DataTable
|
||||
data={cryptoMining} isLoading={loadingCrypto}
|
||||
columns={[
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "MAC Address", accessor: (row: any) => row.mac_address },
|
||||
{ header: "Pool Host", accessor: (row: any) => row.pool_host },
|
||||
{ header: "App", accessor: (row: any) => row.app_label },
|
||||
{ header: "Confidence", accessor: (row: any) => row.confidence },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Tor Detection":
|
||||
return (
|
||||
<DataTable
|
||||
data={torDetection} isLoading={loadingTor}
|
||||
columns={[
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "Exit Node", accessor: (row: any) => row.exit_node },
|
||||
{ header: "Country", accessor: (row: any) => row.country },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "VPN Detection":
|
||||
return (
|
||||
<DataTable
|
||||
data={vpnDetection} isLoading={loadingVpn}
|
||||
columns={[
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "VPN Type", accessor: (row: any) => row.vpn_type },
|
||||
{
|
||||
header: "Remote IP",
|
||||
accessor: (row: any) => row.remote_ip ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{row.remote_ip}</span>
|
||||
<IpDetails ip={row.remote_ip} />
|
||||
</div>
|
||||
) : "—"
|
||||
},
|
||||
{ header: "Country", accessor: (row: any) => row.country },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "IP Reputation":
|
||||
return (
|
||||
<DataTable
|
||||
data={ipReputation} isLoading={loadingIpRep}
|
||||
columns={[
|
||||
{
|
||||
header: "IP Address",
|
||||
accessor: (row: any) => row.ip_address ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{row.ip_address}</span>
|
||||
<IpDetails ip={row.ip_address} />
|
||||
</div>
|
||||
) : "—"
|
||||
},
|
||||
{ header: "Reputation", accessor: (row: any) => row.reputation },
|
||||
{ header: "App", accessor: (row: any) => row.app_label },
|
||||
{ header: "Country", accessor: (row: any) => row.country },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Insecure Protocols":
|
||||
return (
|
||||
<DataTable
|
||||
data={insecureProtocols} isLoading={loadingInsecure}
|
||||
columns={[
|
||||
{ header: "Protocol", accessor: (row: any) => row.protocol },
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "Risk", accessor: (row: any) => row.risk },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Unencrypted Passwords":
|
||||
return (
|
||||
<DataTable
|
||||
data={unencryptedPasswords} isLoading={loadingPasswords}
|
||||
columns={[
|
||||
{ header: "Protocol", accessor: (row: any) => row.protocol },
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "Severity", accessor: (row: any) => row.severity },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Encryption Audit":
|
||||
return (
|
||||
<DataTable
|
||||
data={encryptionAudit} isLoading={loadingEncryption}
|
||||
searchPlaceholder="Search by Device or IP..."
|
||||
searchFilter={(row: any, q: string) => {
|
||||
const query = q.toLowerCase();
|
||||
return (row.ip_address || '').toLowerCase().includes(query) || (row.device_label || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `encryption-audit-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["Device Label", "MAC Address", "IP Address", "Encrypted Data", "Encrypted %", "Total Data", "Risk Level"],
|
||||
rowSerializer: (row: any) => [row.device_label || "", row.mac_address || "", row.ip_address || "", row.encrypted || 0, row.encrypted_pct || 0, row.total || 0, row.risk_level || ""]
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[15%]",
|
||||
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "Device",
|
||||
className: "w-[25%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<span className="font-medium text-white">{row.device_label || row.ip_address}</span>
|
||||
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "IP Address",
|
||||
className: "w-[20%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
|
||||
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
title={row.ip_address ? "Click to view device details" : ""}
|
||||
>
|
||||
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
|
||||
{row.ip_address || "Unknown IP"}
|
||||
</span>
|
||||
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
|
||||
</button>
|
||||
{row.ip_address && <div className="mt-1 scale-90 origin-top"><IpDetails ip={row.ip_address} /></div>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Encrypted Data",
|
||||
className: "w-[26%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<span className="text-green-400 font-mono text-sm">{fmtBytes(row.encrypted)} ({row.encrypted_pct}%)</span>
|
||||
<span className="text-[10px] text-muted-foreground font-medium mt-0.5 uppercase tracking-wider">Total: {fmtBytes(row.total)}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Risk Level",
|
||||
className: "w-[10%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
row.risk_level === 'medium' ? 'bg-yellow-500/20 text-yellow-500' : 'bg-green-500/20 text-green-500'
|
||||
}`}>
|
||||
{row.risk_level === 'medium' ? 'Medium' : 'Safe'}
|
||||
</span>
|
||||
</div>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Device Discovery":
|
||||
return (
|
||||
<DataTable
|
||||
data={deviceDiscovery} isLoading={loadingDevice}
|
||||
searchPlaceholder="Search by IP, MAC, OS..."
|
||||
searchFilter={(row: any, q: string) => {
|
||||
const query = q.toLowerCase();
|
||||
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query) || (row.device_type || '').toLowerCase().includes(query);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `device-discovery-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "MAC Address", "Device Type", "OS", "Manufacturer", "Download", "Upload", "Last Seen"],
|
||||
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.device_type || "", row.os_label || "", row.manufacturer || "", row.download || 0, row.upload || 0, row.last_seen || ""]
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[15%]",
|
||||
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "IP Address",
|
||||
className: "w-[16%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
|
||||
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
title={row.ip_address ? "Click to view device details" : ""}
|
||||
>
|
||||
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
|
||||
{row.ip_address || "Unknown IP"}
|
||||
</span>
|
||||
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ header: "Device Profile", className: "w-[20%]", accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<span className="text-white font-medium">{row.device_type || "-"}</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium mt-0.5">{row.os_label || "-"}</span>
|
||||
</div>
|
||||
) },
|
||||
{ header: "Manufacturer", className: "w-[20%]", accessor: (row: any) => row.manufacturer || "-" },
|
||||
{ header: "Bandwidth", className: "w-[25%]", accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center gap-0.5">
|
||||
<span className="text-cyan-400 text-xs font-mono">↓ {fmtBytes(row.download)}</span>
|
||||
<span className="text-green-400 text-xs font-mono">↑ {fmtBytes(row.upload)}</span>
|
||||
</div>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
case "Server Discovery":
|
||||
return (
|
||||
<DataTable
|
||||
data={serverDiscovery} isLoading={loadingServer}
|
||||
searchPlaceholder="Search by IP, MAC, Type..."
|
||||
searchFilter={(row: any, q: string) => {
|
||||
const query = q.toLowerCase();
|
||||
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.server_type || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `server-discovery-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "MAC Address", "Server Type", "Port", "OS"],
|
||||
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.server_type || "", row.port || "", row.os_label || ""]
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[15%]",
|
||||
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "Server IP",
|
||||
className: "w-[25%]",
|
||||
accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => { if (row.ip_address) setSelectedDevice({ ip: row.ip_address, mac: row.mac_address }); }}
|
||||
className={`flex items-center justify-center gap-1 group text-center ${row.ip_address ? 'cursor-pointer' : 'cursor-default'}`}
|
||||
title={row.ip_address ? "Click to view device details" : ""}
|
||||
>
|
||||
<span className={`font-semibold transition-colors font-mono text-sm ${row.ip_address ? 'text-blue-400 group-hover:text-blue-300' : 'text-muted-foreground'}`}>
|
||||
{row.ip_address || "Unknown IP"}
|
||||
</span>
|
||||
{row.ip_address && <ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground mt-0.5">{row.mac_address}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ header: "Server Type", className: "w-[28%]", accessor: (row: any) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<span className="text-white font-medium">{row.server_type}</span>
|
||||
{row.port > 0 && <span className="text-[10px] text-purple-400 font-mono font-medium mt-0.5">Port {row.port}</span>}
|
||||
</div>
|
||||
) },
|
||||
{ header: "OS / Platform", className: "w-[28%]", accessor: (row: any) => row.os_label },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Threat Intelligence Feeds
|
||||
Threat Intelligence
|
||||
{loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<HelpTrigger pageId="intelligence" />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{intelItems.map((item, idx) => {
|
||||
@@ -414,7 +139,28 @@ export default function IntelligencePage() {
|
||||
<CardTitle>Detailed Report: {activeTab}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{renderActiveTable()}
|
||||
<IntelligenceTables
|
||||
activeTab={activeTab}
|
||||
cryptoMining={cryptoMining}
|
||||
loadingCrypto={loadingCrypto}
|
||||
torDetection={torDetection}
|
||||
loadingTor={loadingTor}
|
||||
vpnDetection={vpnDetection}
|
||||
loadingVpn={loadingVpn}
|
||||
ipReputation={ipReputation}
|
||||
loadingIpRep={loadingIpRep}
|
||||
insecureProtocols={insecureProtocols}
|
||||
loadingInsecure={loadingInsecure}
|
||||
unencryptedPasswords={unencryptedPasswords}
|
||||
loadingPasswords={loadingPasswords}
|
||||
encryptionAudit={encryptionAudit}
|
||||
loadingEncryption={loadingEncryption}
|
||||
deviceDiscovery={deviceDiscovery}
|
||||
loadingDevice={loadingDevice}
|
||||
serverDiscovery={serverDiscovery}
|
||||
loadingServer={loadingServer}
|
||||
setSelectedDevice={setSelectedDevice}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -2,37 +2,39 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Tabs, Tab } from "@/components/ui/Tabs";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import { ApplicationCatalog } from "@/components/lookup/ApplicationCatalog";
|
||||
import { BlacklistConfiguration } from "@/components/lookup/BlacklistConfiguration";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
import { getSiteBranding } from "@/lib/branding";
|
||||
|
||||
export default function LookupPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const branding = getSiteBranding();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
document.title = `App Lookup | ${branding.name} - Deep Package Inspection`;
|
||||
}, [branding.name]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in pb-10">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">
|
||||
Application Database
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight flex items-center gap-3">
|
||||
App Lookup
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-2">
|
||||
Search application lookup catalog and configure traffic blacklist policy rules.
|
||||
Search {branding.name === "BackOne" ? "BackOne's" : `${branding.name}'s`} extensive classification database of over 2,500+ apps, protocols, and services, and manage your blacklist configuration.
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="lookup" />
|
||||
</div>
|
||||
|
||||
{/* Tabs Container */}
|
||||
<Tabs>
|
||||
<Tab label="Application Catalog">
|
||||
<Tab label="App Catalog">
|
||||
<ApplicationCatalog />
|
||||
</Tab>
|
||||
<Tab label="Blacklist Configuration">
|
||||
|
||||
@@ -1,249 +1,253 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Tabs, Tab } from "@/components/ui/Tabs";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import {
|
||||
useRegions, useCities, useVlans, useInterfaces,
|
||||
useIpVersions, useRemoteIps, useMacBandwidth,
|
||||
useFlowTypes, useFlowOrigins
|
||||
} from "@/lib/api-advanced";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
export default function NetworkInfrastructurePage() {
|
||||
const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null);
|
||||
|
||||
// Routing & Geography Tab
|
||||
const { data: regions, isLoading: loadingRegions } = useRegions(20);
|
||||
const { data: cities, isLoading: loadingCities } = useCities(20);
|
||||
|
||||
// Interfaces & VLANs Tab
|
||||
const { data: interfaces, isLoading: loadingInterfaces } = useInterfaces(20);
|
||||
const { data: vlans, isLoading: loadingVlans } = useVlans(20);
|
||||
|
||||
// IP & MAC Tab
|
||||
const { data: remoteIps, isLoading: loadingRemoteIps } = useRemoteIps(20);
|
||||
const { data: ipVersions, isLoading: loadingIpVersions } = useIpVersions(20);
|
||||
const { data: macBandwidth, isLoading: loadingMacBandwidth } = useMacBandwidth(20);
|
||||
|
||||
// Flow Profiles Tab
|
||||
const { data: flowTypes, isLoading: loadingFlowTypes } = useFlowTypes(20);
|
||||
const { data: flowOrigins, isLoading: loadingFlowOrigins } = useFlowOrigins(20);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0 || !bytes) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">Network Infrastructure</h1>
|
||||
<p className="text-slate-400 mt-2">Physical, logical, and geographic network topologies.</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="network-infrastructure" />
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
<Tab label="Routing & Geography">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Regions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={regions}
|
||||
isLoading={loadingRegions}
|
||||
columns={[
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Cities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={cities}
|
||||
isLoading={loadingCities}
|
||||
columns={[
|
||||
{ header: "City", accessor: (row: any) => <span title={row.city_name}>{row.city_name}</span> },
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="Interfaces & VLANs">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Network Interfaces</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={interfaces}
|
||||
isLoading={loadingInterfaces}
|
||||
columns={[
|
||||
{ header: "Interface", accessor: (row: any) => row.iface_name },
|
||||
{ header: "Role", accessor: (row: any) => row.iface_role },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>VLANs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={vlans}
|
||||
isLoading={loadingVlans}
|
||||
columns={[
|
||||
{ header: "VLAN ID", accessor: (row: any) => row.vlan_id },
|
||||
{ header: "Label", accessor: (row: any) => row.vlan_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="IP & MAC Properties">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Remote IPs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={remoteIps}
|
||||
isLoading={loadingRemoteIps}
|
||||
onRowClick={(row) => setSelectedRemoteIp(row.remote_ip)}
|
||||
columns={[
|
||||
{
|
||||
header: "IP Address",
|
||||
accessor: (row: any) => row.remote_ip ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{row.remote_ip}</span>
|
||||
<IpDetails ip={row.remote_ip} />
|
||||
</div>
|
||||
) : "—"
|
||||
},
|
||||
{ header: "IP Version", accessor: (row) => `IPv${row.ip_version}` },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IP Versions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={ipVersions}
|
||||
isLoading={loadingIpVersions}
|
||||
columns={[
|
||||
{ header: "Version", accessor: (row: any) => row.ip_version_label },
|
||||
{ header: "Total Traffic", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MAC Bandwidth</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={macBandwidth}
|
||||
isLoading={loadingMacBandwidth}
|
||||
columns={[
|
||||
{ header: "MAC Address", accessor: (row: any) => <span title={row.mac_address}>{row.mac_address}</span> },
|
||||
{ header: "Manufacturer", accessor: (row: any) => <span title={row.manufacturer}>{row.manufacturer}</span> },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="Flow Profiles">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flow Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={flowTypes}
|
||||
isLoading={loadingFlowTypes}
|
||||
columns={[
|
||||
{ header: "Flow Type", accessor: (row: any) => row.flow_type_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flow Origins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={flowOrigins}
|
||||
isLoading={loadingFlowOrigins}
|
||||
columns={[
|
||||
{ header: "Origin", accessor: (row: any) => row.flow_origin_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{selectedRemoteIp && (
|
||||
<RemoteIpDetailModal
|
||||
ip={selectedRemoteIp}
|
||||
onClose={() => setSelectedRemoteIp(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Tabs, Tab } from "@/components/ui/Tabs";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import {
|
||||
useRegions, useCities, useVlans, useInterfaces,
|
||||
useIpVersions, useRemoteIps, useMacBandwidth,
|
||||
useFlowTypes, useFlowOrigins
|
||||
} from "@/lib/api-advanced";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
export default function NetworkInfrastructurePage() {
|
||||
const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Network Topology | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
// Routing & Geography Tab
|
||||
const { data: regions, isLoading: loadingRegions } = useRegions();
|
||||
const { data: cities, isLoading: loadingCities } = useCities();
|
||||
|
||||
// Interfaces & VLANs Tab
|
||||
const { data: interfaces, isLoading: loadingInterfaces } = useInterfaces();
|
||||
const { data: vlans, isLoading: loadingVlans } = useVlans();
|
||||
|
||||
// IP & MAC Tab
|
||||
const { data: remoteIps, isLoading: loadingRemoteIps } = useRemoteIps();
|
||||
const { data: ipVersions, isLoading: loadingIpVersions } = useIpVersions();
|
||||
const { data: macBandwidth, isLoading: loadingMacBandwidth } = useMacBandwidth();
|
||||
|
||||
// Flow Profiles Tab
|
||||
const { data: flowTypes, isLoading: loadingFlowTypes } = useFlowTypes();
|
||||
const { data: flowOrigins, isLoading: loadingFlowOrigins } = useFlowOrigins();
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0 || !bytes) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">Network Topology</h1>
|
||||
<p className="text-slate-400 mt-2">Physical, logical, and geographic network topologies.</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="network-infrastructure" />
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
<Tab label="Routing & Geography">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Regions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={regions}
|
||||
isLoading={loadingRegions}
|
||||
columns={[
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Cities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={cities}
|
||||
isLoading={loadingCities}
|
||||
columns={[
|
||||
{ header: "City", accessor: (row: any) => <span title={row.city_name}>{row.city_name}</span> },
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="Interfaces & VLANs">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Network Interfaces</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={interfaces}
|
||||
isLoading={loadingInterfaces}
|
||||
columns={[
|
||||
{ header: "Interface", accessor: (row: any) => row.iface_name },
|
||||
{ header: "Role", accessor: (row: any) => row.iface_role },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>VLANs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={vlans}
|
||||
isLoading={loadingVlans}
|
||||
columns={[
|
||||
{ header: "VLAN ID", accessor: (row: any) => row.vlan_id },
|
||||
{ header: "Label", accessor: (row: any) => row.vlan_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="IP & MAC Properties">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Remote IPs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={remoteIps}
|
||||
isLoading={loadingRemoteIps}
|
||||
onRowClick={(row) => setSelectedRemoteIp(row.remote_ip)}
|
||||
columns={[
|
||||
{
|
||||
header: "IP Address",
|
||||
accessor: (row: any) => row.remote_ip ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{row.remote_ip}</span>
|
||||
<IpDetails ip={row.remote_ip} />
|
||||
</div>
|
||||
) : "—"
|
||||
},
|
||||
{ header: "IP Version", accessor: (row) => `IPv${row.ip_version}` },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IP Versions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={ipVersions}
|
||||
isLoading={loadingIpVersions}
|
||||
columns={[
|
||||
{ header: "Version", accessor: (row: any) => row.ip_version_label },
|
||||
{ header: "Total Traffic", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MAC Bandwidth</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={macBandwidth}
|
||||
isLoading={loadingMacBandwidth}
|
||||
columns={[
|
||||
{ header: "MAC Address", accessor: (row: any) => <span title={row.mac_address}>{row.mac_address}</span> },
|
||||
{ header: "Manufacturer", accessor: (row: any) => <span title={row.manufacturer}>{row.manufacturer}</span> },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="Flow Profiles">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flow Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={flowTypes}
|
||||
isLoading={loadingFlowTypes}
|
||||
columns={[
|
||||
{ header: "Flow Type", accessor: (row: any) => row.flow_type_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flow Origins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={flowOrigins}
|
||||
isLoading={loadingFlowOrigins}
|
||||
columns={[
|
||||
{ header: "Origin", accessor: (row: any) => row.flow_origin_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{selectedRemoteIp && (
|
||||
<RemoteIpDetailModal
|
||||
ip={selectedRemoteIp}
|
||||
onClose={() => setSelectedRemoteIp(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export default function NetworkIntelligencePage() {
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Traffic Categories | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
@@ -64,26 +65,26 @@ export default function NetworkIntelligencePage() {
|
||||
}
|
||||
|
||||
const appCols: Column<AppCategoryStat>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[10%] text-center" },
|
||||
{
|
||||
header: "Category",
|
||||
accessor: (row) => (
|
||||
<div title={row.category_label || '-'} className="font-semibold text-white truncate w-full block text-center cursor-help">
|
||||
<div title={row.category_label || '-'} className="font-semibold text-white truncate w-full block text-center cursor-help animate-pulse-subtle">
|
||||
<span className="border-b border-dashed border-white/30 pb-0.5">{row.category_label || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
className: "w-[180px] min-w-[180px] max-w-[180px]"
|
||||
className: "w-[35%] text-center"
|
||||
},
|
||||
{ header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "w-[120px] min-w-[120px] max-w-[120px]" },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total), className: "w-[120px] min-w-[120px] max-w-[120px]" },
|
||||
{ header: "Download", accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>, className: "w-[18%] text-center" },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>, className: "w-[18%] text-center" },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total), className: "w-[19%] text-center" },
|
||||
];
|
||||
|
||||
const isLoading = appCategories.isLoading || continents.isLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Traffic Categories
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function SummaryPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Overview Dashboard | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
@@ -83,10 +84,10 @@ export default function SummaryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Summary Overview
|
||||
Overview Dashboard
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Real-time network traffic and intelligence summary.</p>
|
||||
|
||||
@@ -15,9 +15,9 @@ import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
export default function SecurityAuditPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const versions = useTlsVersions(10);
|
||||
const ciphers = useTlsCiphers(20);
|
||||
const security = useTlsSecurity(10);
|
||||
const versions = useTlsVersions();
|
||||
const ciphers = useTlsCiphers();
|
||||
const security = useTlsSecurity();
|
||||
|
||||
const [secDevices, setSecDevices] = useState<SecurityDevice[]>([]);
|
||||
const [secDevicesLoading, setSecDevicesLoading] = useState(true);
|
||||
@@ -54,14 +54,11 @@ export default function SecurityAuditPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">Security & Encryption Audit</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles.
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="security-audit" />
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">Security & Encryption Audit</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
|
||||
@@ -1,322 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { useState, useEffect, useMemo, Suspense } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { ShieldCheck, Loader2 } from "lucide-react";
|
||||
import { ThreatStat } from "@/lib/api";
|
||||
import { useThreats } from "@/lib/api-with-context";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { ThreatFilters } from "@/components/threats/ThreatFilters";
|
||||
import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
// ─── ThreatsContent ───────────────────────────────────────────────────────────
|
||||
function ThreatsContent() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
const [selectedMac, setSelectedMac] = useState<string | null>(null);
|
||||
const [selectedThreat, setSelectedThreat] = useState<ThreatStat | null>(null);
|
||||
|
||||
const threats = useThreats();
|
||||
const searchParams = useSearchParams();
|
||||
const highlightId = searchParams.get("highlight");
|
||||
|
||||
// Column dropdown filters state
|
||||
const [fType, setFType] = useState("All");
|
||||
const [fSev, setFSev] = useState("All");
|
||||
const [fSrcIp, setFSrcIp] = useState("All");
|
||||
const [fDstIp, setFDstIp] = useState("All");
|
||||
const [fMac, setFMac] = useState("All");
|
||||
const [fApp, setFApp] = useState("All");
|
||||
const [fDomain, setFDomain] = useState("All");
|
||||
|
||||
// Date range filter state
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const all = threats.data || [];
|
||||
|
||||
// Apply all filters
|
||||
const filtered = useMemo(() => {
|
||||
return all.filter((row) => {
|
||||
if (fType !== "All" && (row.threat_type || "") !== fType) return false;
|
||||
if (fSev !== "All" && (row.severity || "") !== fSev) return false;
|
||||
if (fSrcIp !== "All" && (row.ip_address || "") !== fSrcIp) return false;
|
||||
if (fDstIp !== "All" && (row.dst_ip || "") !== fDstIp) return false;
|
||||
if (fMac !== "All" && (row.mac_address || "") !== fMac) return false;
|
||||
if (fApp !== "All" && (row.app_label || "") !== fApp) return false;
|
||||
if (fDomain !== "All" && (row.domain || "") !== fDomain) return false;
|
||||
if (dateFrom || dateTo) {
|
||||
const d = row.detected_at
|
||||
? new Date(row.detected_at).toISOString().slice(0, 10)
|
||||
: "";
|
||||
if (dateFrom && d < dateFrom) return false;
|
||||
if (dateTo && d > dateTo) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]);
|
||||
|
||||
// Automatically select a threat if highlightId is passed
|
||||
useEffect(() => {
|
||||
if (highlightId && all.length > 0) {
|
||||
const match = all.find(r => r.id.toString() === highlightId);
|
||||
if (match) {
|
||||
setSelectedThreat(match);
|
||||
}
|
||||
}
|
||||
}, [highlightId, all]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const getSeverityBadge = (sev?: string | null) => {
|
||||
if (!sev) return <Badge variant="green">Unknown</Badge>;
|
||||
const s = sev.toLowerCase();
|
||||
if (s.includes("high") || s.includes("critical")) return <Badge variant="red">{sev}</Badge>;
|
||||
if (s.includes("med")) return <Badge variant="orange">{sev}</Badge>;
|
||||
if (s.includes("low")) return <Badge variant="yellow">{sev}</Badge>;
|
||||
return <Badge variant="blue">{sev}</Badge>;
|
||||
};
|
||||
|
||||
const columns: Column<ThreatStat>[] = [
|
||||
{
|
||||
header: "Timestamp",
|
||||
className: "w-[155px] min-w-[155px] max-w-[155px] text-center",
|
||||
accessor: (row) => <TimestampCell timestamp={row.detected_at} showActiveStatus={false} />
|
||||
},
|
||||
{ header: "#", className: "w-[40px] min-w-[40px] max-w-[40px] text-center", accessor: (_row, i) => i + 1 },
|
||||
{
|
||||
header: "Type",
|
||||
className: "w-[170px] min-w-[170px] max-w-[170px]",
|
||||
accessor: (row) => (
|
||||
<div
|
||||
title={row.description || row.threat_type || "Unknown"}
|
||||
className="truncate w-full block cursor-help text-xs leading-tight"
|
||||
>
|
||||
{row.threat_type || "Unknown"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Severity",
|
||||
className: "w-[90px] min-w-[90px] max-w-[90px] text-center",
|
||||
accessor: (row) => getSeverityBadge(row.severity),
|
||||
},
|
||||
{
|
||||
header: "Source IP",
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
|
||||
accessor: (row) =>
|
||||
row.ip_address ? (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.ip_address)}
|
||||
className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center"
|
||||
>
|
||||
{row.ip_address}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="Source IP tidak tersedia dari data event keamanan (hanya MAC Address yang ter-capture)">–</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Dest IP",
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px] text-center",
|
||||
accessor: (row) =>
|
||||
row.dst_ip ? (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.dst_ip)}
|
||||
className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center"
|
||||
>
|
||||
{row.dst_ip}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="Dest IP tidak tersedia dari data event ini">–</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "MAC Addr",
|
||||
className: "w-[140px] min-w-[140px] max-w-[140px] text-center",
|
||||
accessor: (row) =>
|
||||
row.mac_address ? (
|
||||
<button
|
||||
onClick={() => setSelectedMac(row.mac_address)}
|
||||
className="text-xs bg-secondary/50 px-1.5 py-0.5 rounded text-muted-foreground hover:text-white hover:bg-secondary transition-colors font-mono focus:outline-none w-full text-center truncate"
|
||||
title={row.mac_address}
|
||||
>
|
||||
{row.mac_address}
|
||||
</button>
|
||||
) : "–",
|
||||
},
|
||||
{
|
||||
header: "App",
|
||||
className: "w-[110px] min-w-[110px] max-w-[110px] text-center",
|
||||
accessor: (row) => {
|
||||
const formatted = formatAppLabel(row.app_label);
|
||||
return formatted ? (
|
||||
<div title={formatted} className="truncate w-full block text-center cursor-help text-xs">
|
||||
{formatted}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="App tidak tersedia dari event ini">–</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Domain",
|
||||
className: "w-[160px] min-w-[160px] max-w-[160px] text-center",
|
||||
accessor: (row) => row.domain ? (
|
||||
<div title={row.domain} className="truncate w-full text-center text-xs cursor-help">
|
||||
{row.domain}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="Domain tidak tersedia dari event ini">–</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
className: "w-[130px] min-w-[130px] max-w-[130px] text-center",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setSelectedThreat(row)}
|
||||
className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer whitespace-nowrap focus:outline-none"
|
||||
>
|
||||
View Mitigation
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const activeFilterCount =
|
||||
[fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain].filter((v) => v !== "All").length +
|
||||
(dateFrom || dateTo ? 1 : 0);
|
||||
|
||||
const resetFilters = () => {
|
||||
setFType("All");
|
||||
setFSev("All");
|
||||
setFSrcIp("All");
|
||||
setFDstIp("All");
|
||||
setFMac("All");
|
||||
setFApp("All");
|
||||
setFDomain("All");
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Threat Intelligence
|
||||
{threats.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<HelpTrigger pageId="intelligence" />
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<span>Active Monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters above the table */}
|
||||
<ThreatFilters
|
||||
all={all}
|
||||
fType={fType}
|
||||
setFType={setFType}
|
||||
fSev={fSev}
|
||||
setFSev={setFSev}
|
||||
fSrcIp={fSrcIp}
|
||||
setFSrcIp={setFSrcIp}
|
||||
fDstIp={fDstIp}
|
||||
setFDstIp={setFDstIp}
|
||||
fMac={fMac}
|
||||
setFMac={setFMac}
|
||||
fApp={fApp}
|
||||
setFApp={setFApp}
|
||||
fDomain={fDomain}
|
||||
setFDomain={setFDomain}
|
||||
dateFrom={dateFrom}
|
||||
setDateFrom={setDateFrom}
|
||||
dateTo={dateTo}
|
||||
setDateTo={setDateTo}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
{/* Threats Table - Full Width */}
|
||||
<Card
|
||||
className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up relative z-10 overflow-visible"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-lg text-white">
|
||||
Detected Threats
|
||||
<span className="ml-2 text-muted-foreground font-normal">
|
||||
({filtered.length}
|
||||
{filtered.length !== all.length ? ` of ${all.length}` : ""})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={filtered}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search threats by IP, domain, type..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.domain || "").toLowerCase().includes(q) ||
|
||||
(row.threat_type || "").toLowerCase().includes(q) ||
|
||||
(row.app_label || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
getRowClassName={(row) =>
|
||||
highlightId && row.id.toString() === highlightId
|
||||
? "bg-red-500/15 border-l-2 border-red-500 animate-pulse font-semibold"
|
||||
: ""
|
||||
}
|
||||
csvExport={{
|
||||
filename: `threats-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
headers: ["Timestamp", "Type", "Severity", "Source IP", "Dest IP", "MAC Address", "App", "Domain"],
|
||||
rowSerializer: (row) => [
|
||||
row.detected_at ? new Date(row.detected_at).toISOString() : "",
|
||||
row.threat_type || "",
|
||||
row.severity || "",
|
||||
row.ip_address || "",
|
||||
row.dst_ip || "",
|
||||
row.mac_address || "",
|
||||
row.app_label || "",
|
||||
row.domain || "",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedIp && <DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />}
|
||||
{selectedMac && <DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />}
|
||||
|
||||
{/* Mitigation recommendations drawer panel */}
|
||||
<ThreatRecommendationsDrawer
|
||||
threat={selectedThreat}
|
||||
onClose={() => setSelectedThreat(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Suspense } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import ThreatsContent from "@/components/threats/ThreatsContent";
|
||||
|
||||
export default function ThreatsPage() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { Users, Plus, ShieldAlert } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { type ManagedUser } from "@/lib/admin-api";
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
|
||||
export interface CompanyGroup {
|
||||
name: string;
|
||||
users: ManagedUser[];
|
||||
}
|
||||
|
||||
interface CompanyCardProps {
|
||||
company: CompanyGroup;
|
||||
role: string | null;
|
||||
columns: Column<ManagedUser>[];
|
||||
onAddUserClick: (companyName: string) => void;
|
||||
}
|
||||
|
||||
export default function CompanyCard({
|
||||
company,
|
||||
role,
|
||||
columns,
|
||||
onAddUserClick,
|
||||
}: CompanyCardProps) {
|
||||
const count = company.users.length;
|
||||
const isLimitReached = count >= 5;
|
||||
|
||||
return (
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50 overflow-hidden shadow-xl shadow-black/10">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3 border-b border-white/5 bg-white/[0.01]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-500/10 border border-amber-500/20 rounded-lg shadow-sm">
|
||||
<Users className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-white text-base font-bold">{company.name} Accounts</CardTitle>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs px-2.5 py-0.5 rounded-full font-semibold border ${
|
||||
isLimitReached
|
||||
? "bg-red-500/10 text-red-400 border-red-500/20"
|
||||
: "bg-slate-500/10 text-slate-400 border-slate-500/20"
|
||||
}`}>
|
||||
Accounts: {count} / 5
|
||||
</span>
|
||||
{isLimitReached && (
|
||||
<span className="text-[10px] text-red-400 flex items-center gap-1">
|
||||
<ShieldAlert className="w-3.5 h-3.5" /> Max limit reached
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{role !== "EXECUTIVE" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddUserClick(company.name)}
|
||||
disabled={isLimitReached}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-lg transition-all border cursor-pointer ${
|
||||
isLimitReached
|
||||
? "bg-slate-800 text-slate-500 border-slate-700 cursor-not-allowed"
|
||||
: "bg-amber-600/10 text-amber-400 hover:bg-amber-600/20 border-amber-500/30 hover:border-amber-500/50 shadow-md shadow-amber-600/5"
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add User for {company.name}
|
||||
</button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<DataTable
|
||||
data={company.users}
|
||||
columns={columns}
|
||||
searchPlaceholder={`Search users in ${company.name}...`}
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
(row.username || "").toLowerCase().includes(q) ||
|
||||
(row.account_name || "").toLowerCase().includes(q) ||
|
||||
(row.role || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// src/app/(dashboard)/user-accounts/columns.tsx
|
||||
"use client";
|
||||
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
import { type ManagedUser } from "@/lib/admin-api";
|
||||
import { Eye, Loader2 } from "lucide-react";
|
||||
|
||||
interface GetColumnsProps {
|
||||
role: string | null;
|
||||
currentUsername?: string | null;
|
||||
viewAsLoading?: string | null;
|
||||
onEditClick: (user: ManagedUser) => void;
|
||||
onViewAsClick?: (user: ManagedUser) => void;
|
||||
}
|
||||
|
||||
export function getColumns({
|
||||
role,
|
||||
currentUsername,
|
||||
viewAsLoading,
|
||||
onEditClick,
|
||||
onViewAsClick,
|
||||
}: GetColumnsProps): Column<ManagedUser>[] {
|
||||
return [
|
||||
{
|
||||
header: "#",
|
||||
className: "w-[5%] text-center",
|
||||
accessor: (_, i) => <span className="text-slate-400 font-mono">{i + 1}</span>
|
||||
},
|
||||
{
|
||||
header: "Account Name",
|
||||
className: "w-[22%] text-center",
|
||||
accessor: (row) => (
|
||||
<span className="text-sm font-medium text-slate-200">
|
||||
{row.account_name || row.username}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Username",
|
||||
className: "w-[18%] text-center",
|
||||
accessor: (row) => <code className="text-xs text-slate-400 font-mono">{row.username}</code>
|
||||
},
|
||||
{
|
||||
header: "Role",
|
||||
className: "w-[18%] text-center",
|
||||
accessor: (row) => {
|
||||
let badgeColor = "bg-slate-500/20 text-slate-400 border-slate-500/30";
|
||||
if (row.role === "COMPANY_ADMIN") badgeColor = "bg-purple-500/20 text-purple-400 border-purple-500/30";
|
||||
if (row.role === "COMPANY_OPERATOR") badgeColor = "bg-amber-500/20 text-amber-400 border-amber-500/30";
|
||||
if (row.role === "COMPANY_VIEWER") badgeColor = "bg-blue-500/20 text-blue-400 border-blue-500/30";
|
||||
|
||||
return (
|
||||
<span className={`px-2.5 py-1 rounded-full text-[10px] font-bold border uppercase tracking-wider ${badgeColor}`}>
|
||||
{row.role.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Assigned Devices",
|
||||
className: "w-[15%] text-center",
|
||||
accessor: (row) => {
|
||||
const uuids = row.agent_uuids || [];
|
||||
if (uuids.length === 0) return <span className="text-xs text-slate-500 italic">No device assigned</span>;
|
||||
return (
|
||||
<span className="text-xs font-mono text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2 py-0.5 rounded">
|
||||
{uuids.length} Device{uuids.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "View As",
|
||||
className: "w-[10%] text-center",
|
||||
accessor: (row) => {
|
||||
const isSelf = currentUsername && row.username === currentUsername;
|
||||
const isCompanyAdmin = row.role === "COMPANY_ADMIN";
|
||||
// View-As tersedia untuk COMPANY_OPERATOR dan COMPANY_VIEWER (bukan diri sendiri, bukan admin)
|
||||
const canViewAs = onViewAsClick &&
|
||||
!isSelf &&
|
||||
!isCompanyAdmin &&
|
||||
(row.role === "COMPANY_OPERATOR" || row.role === "COMPANY_VIEWER") &&
|
||||
(role === "SUPER_ADMIN" || role === "COMPANY_ADMIN");
|
||||
|
||||
if (!canViewAs) {
|
||||
return <span className="text-slate-600 text-xs">—</span>;
|
||||
}
|
||||
|
||||
const isLoading = viewAsLoading === row.username;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onViewAsClick(row)}
|
||||
disabled={isLoading}
|
||||
title={`View dashboard as ${row.account_name || row.username}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-semibold text-amber-400 bg-amber-400/10 border border-amber-400/20 hover:bg-amber-400/20 hover:border-amber-400/40 transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<Eye className="w-3 h-3" />
|
||||
)}
|
||||
View As
|
||||
</button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) => {
|
||||
const isSelf = currentUsername && row.username === currentUsername;
|
||||
const isDisabled = role === "EXECUTIVE" || isSelf;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isSelf && onEditClick(row)}
|
||||
disabled={!!isDisabled}
|
||||
title={isSelf ? "Cannot edit your own account from here" : undefined}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed hover:underline font-medium cursor-pointer"
|
||||
>
|
||||
{isSelf ? "👤 (You)" : "⚙️ Edit User"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Users, Plus } from "lucide-react";
|
||||
import { getAdminUsers, getViewAsHeaders, startViewAsUser, type ManagedUser } from "@/lib/admin-api";
|
||||
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import ExternalAccountModal from "@/components/admin/ExternalAccountModal";
|
||||
import { getColumns } from "./columns";
|
||||
import CompanyCard, { CompanyGroup } from "./CompanyCard";
|
||||
|
||||
export default function UserAccountsPage() {
|
||||
const router = useRouter();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [role, setRole] = useState<string | null>(null);
|
||||
const [currentUsername, setCurrentUsername] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [modalUser, setModalUser] = useState<ManagedUser | null>(null);
|
||||
const [targetCompany, setTargetCompany] = useState<string | null>(null);
|
||||
const [viewAsLoading, setViewAsLoading] = useState<string | null>(null);
|
||||
// Registry agents untuk lookup label agent (digunakan pada View-As banner)
|
||||
const [agentRegistry, setAgentRegistry] = useState<Record<string, string>>({});
|
||||
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const users = await getAdminUsers();
|
||||
setManagedUsers(users);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to retrieve user data.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "User Accounts | BackOne - Deep Package Inspection";
|
||||
|
||||
fetch("/api/auth/me", { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
const userRole = data.user.role || null;
|
||||
setRole(userRole);
|
||||
setCurrentUsername(data.user.username || null);
|
||||
if (userRole !== "SUPER_ADMIN" && userRole !== "EXECUTIVE" && userRole !== "COMPANY_ADMIN") {
|
||||
router.push("/");
|
||||
} else {
|
||||
loadData();
|
||||
// Juga load agent registry untuk label lookup (digunakan pada banner View-As)
|
||||
fetch("/api/dashboard/agents/list", { headers: getViewAsHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(json => {
|
||||
if (json.ok && Array.isArray(json.data)) {
|
||||
const map: Record<string, string> = {};
|
||||
json.data.forEach((a: any) => {
|
||||
if (a.uuid) map[a.uuid] = a.label || a.uuid;
|
||||
});
|
||||
setAgentRegistry(map);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
// User Accounts = HANYA akun customer/client (COMPANY_* roles)
|
||||
// Role teknikal/operasional (SOC_ANALYST, EXECUTIVE, ENGINEER, dll) ada di halaman Agents
|
||||
const COMPANY_ROLES = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'];
|
||||
const companyUsers = managedUsers.filter(u => COMPANY_ROLES.includes(u.role || '') && u.company_name);
|
||||
|
||||
const companiesMap: Record<string, ManagedUser[]> = {};
|
||||
companyUsers.forEach(u => {
|
||||
if (u.company_name) {
|
||||
if (!companiesMap[u.company_name]) {
|
||||
companiesMap[u.company_name] = [];
|
||||
}
|
||||
companiesMap[u.company_name].push(u);
|
||||
}
|
||||
});
|
||||
|
||||
const companies: CompanyGroup[] = Object.entries(companiesMap).map(([name, users]) => ({
|
||||
name,
|
||||
users
|
||||
}));
|
||||
|
||||
const handleEditClick = (user: ManagedUser) => {
|
||||
setModalUser(user);
|
||||
setTargetCompany(user.company_name || null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleAddUserClick = (companyName: string) => {
|
||||
setModalUser(null);
|
||||
setTargetCompany(companyName);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleViewAsUser = async (user: ManagedUser) => {
|
||||
// View-As berdasarkan agent_uuids pertama yang di-assign ke user target
|
||||
const agentUuids = user.agent_uuids || [];
|
||||
if (agentUuids.length === 0) {
|
||||
alert(`User "${user.account_name || user.username}" has no assigned agents. Cannot enter View-As mode.`);
|
||||
return;
|
||||
}
|
||||
const targetAgentUuid = agentUuids[0];
|
||||
// Gunakan label agent dari registry (bukan UUID) untuk banner yang informatif
|
||||
const agentLabel = agentRegistry[targetAgentUuid] || targetAgentUuid;
|
||||
const userName = user.account_name || user.username;
|
||||
const userRole = user.role;
|
||||
setViewAsLoading(user.username);
|
||||
try {
|
||||
// startViewAsUser menyimpan: agent_label (label agent), user_name, user_role, view_as_type='user'
|
||||
await startViewAsUser(targetAgentUuid, agentLabel, userName, userRole);
|
||||
router.refresh();
|
||||
window.location.reload();
|
||||
} catch (err: any) {
|
||||
alert('Failed to enter View As mode: ' + err.message);
|
||||
} finally {
|
||||
setViewAsLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const columns = getColumns({
|
||||
role,
|
||||
currentUsername,
|
||||
viewAsLoading,
|
||||
onEditClick: handleEditClick,
|
||||
onViewAsClick: (role === "SUPER_ADMIN" || role === "COMPANY_ADMIN") ? handleViewAsUser : undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-white flex items-center gap-3">
|
||||
User Accounts Directory
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-amber-500" />}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage company tenant user access, roles, and device ownership delegation.
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="user-accounts" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/15 border border-red-500/30 text-red-400 px-4 py-3 rounded-xl text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-10 h-10 animate-spin text-amber-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{companies.length === 0 ? (
|
||||
<div className="max-w-md mx-auto py-12 px-6">
|
||||
<div className="relative overflow-hidden bg-white/[0.02] backdrop-blur-md border border-white/10 rounded-3xl p-8 text-center shadow-2xl shadow-black/50">
|
||||
{/* Ambient glow highlight */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative w-16 h-16 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-5 text-amber-400 shadow-lg shadow-amber-500/5 animate-pulse-subtle">
|
||||
<Users className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<h3 className="relative text-white font-bold text-lg mb-1.5">No Company Accounts</h3>
|
||||
<p className="relative text-sm text-slate-400 mb-6 max-w-xs mx-auto">
|
||||
Create the first company tenant admin account to get started.
|
||||
</p>
|
||||
|
||||
{role === "SUPER_ADMIN" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddUserClick("")}
|
||||
className="relative inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 text-white rounded-xl text-xs font-semibold shadow-md shadow-amber-600/10 hover:shadow-lg transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Company Admin
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
companies.map((company) => (
|
||||
<CompanyCard
|
||||
key={company.name}
|
||||
company={company}
|
||||
role={role}
|
||||
columns={columns}
|
||||
onAddUserClick={handleAddUserClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{role === "SUPER_ADMIN" && companies.length > 0 && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddUserClick("")}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-xs font-semibold bg-amber-600 hover:bg-amber-700 text-white rounded-xl transition-all shadow-md shadow-amber-600/10 hover:shadow-lg cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add New Company Admin
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Creation / Edit Modal */}
|
||||
<ExternalAccountModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setModalUser(null);
|
||||
setTargetCompany(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setIsModalOpen(false);
|
||||
setModalUser(null);
|
||||
setTargetCompany(null);
|
||||
loadData();
|
||||
}}
|
||||
user={modalUser}
|
||||
targetCompany={targetCompany}
|
||||
targetSiteUuid={null}
|
||||
targetCreatedBy={role === "SUPER_ADMIN" ? "admin" : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+18
-408
@@ -1,419 +1,31 @@
|
||||
@import "tailwindcss";
|
||||
@import "./theme.css";
|
||||
@import "./variables.css";
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--font-backone: var(--font-backone);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
|
||||
/* OVERRIDE TAILWIND HARDCODED COLORS FOR SEAMLESS LIGHT MODE */
|
||||
--color-slate-900: var(--slate-900);
|
||||
--color-slate-800: var(--slate-800);
|
||||
--color-slate-700: var(--slate-700);
|
||||
--color-slate-600: var(--slate-600);
|
||||
--color-slate-500: var(--slate-500);
|
||||
--color-slate-400: var(--slate-400);
|
||||
--color-slate-300: var(--slate-300);
|
||||
--color-slate-200: var(--slate-200);
|
||||
--color-slate-100: var(--slate-100);
|
||||
--color-slate-50: var(--slate-50);
|
||||
|
||||
--color-gray-900: var(--slate-900);
|
||||
--color-gray-800: var(--slate-800);
|
||||
--color-gray-700: var(--slate-700);
|
||||
--color-gray-600: var(--slate-600);
|
||||
--color-gray-500: var(--slate-500);
|
||||
--color-gray-400: var(--slate-400);
|
||||
--color-gray-300: var(--slate-300);
|
||||
--color-gray-200: var(--slate-200);
|
||||
--color-gray-100: var(--slate-100);
|
||||
--color-gray-50: var(--slate-50);
|
||||
|
||||
--color-white: var(--t-white);
|
||||
--color-black: var(--t-black);
|
||||
|
||||
--font-sans: var(--font-inter);
|
||||
--font-mono: var(--font-inter);
|
||||
|
||||
/* Micro-animations */
|
||||
--animate-fade-in: fade-in 0.5s ease-out forwards;
|
||||
--animate-slide-up: slide-up 0.5s ease-out forwards;
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Premium Dark Theme */
|
||||
--background: #09090b;
|
||||
/* Very dark zinc */
|
||||
--foreground: #fafafa;
|
||||
|
||||
--card: rgba(24, 24, 27, 0.7);
|
||||
/* Zinc 900 with transparency for glassmorphism */
|
||||
--card-foreground: #fafafa;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--primary: #3b82f6;
|
||||
/* Blue 500 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(39, 39, 42, 0.8);
|
||||
/* Zinc 800 */
|
||||
--secondary-foreground: #fafafa;
|
||||
|
||||
--muted: rgba(39, 39, 42, 0.5);
|
||||
--muted-foreground: #a1a1aa;
|
||||
/* Zinc 400 */
|
||||
|
||||
--destructive: #ef4444;
|
||||
/* Red 500 */
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--sidebar: #09090b;
|
||||
|
||||
/* Overrides for hardcoded colors in Dark Mode (Normal) */
|
||||
--t-white: #ffffff;
|
||||
--t-black: #000000;
|
||||
--slate-900: #0f172a;
|
||||
--slate-800: #1e293b;
|
||||
--slate-700: #334155;
|
||||
--slate-600: #475569;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #94a3b8;
|
||||
--slate-300: #cbd5e1;
|
||||
--slate-200: #e2e8f0;
|
||||
--slate-100: #f1f5f9;
|
||||
--slate-50: #f8fafc;
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Clean Light Theme */
|
||||
--background: #f1f5f9;
|
||||
/* Slate 100 */
|
||||
--foreground: #0f172a;
|
||||
|
||||
--card: rgba(255, 255, 255, 0.9);
|
||||
--card-foreground: #0f172a;
|
||||
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
|
||||
--primary: #2563eb;
|
||||
/* Blue 600 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(241, 245, 249, 0.8);
|
||||
/* Slate 100 */
|
||||
--secondary-foreground: #0f172a;
|
||||
|
||||
--muted: rgba(226, 232, 240, 0.5);
|
||||
/* Slate 200 */
|
||||
--muted-foreground: #64748b;
|
||||
/* Slate 500 */
|
||||
|
||||
--sidebar: #ffffff;
|
||||
|
||||
/* Overrides for hardcoded colors in Light Mode (Inverted) */
|
||||
--t-white: #0f172a;
|
||||
/* Make text-white become very dark slate */
|
||||
--t-black: #ffffff;
|
||||
--slate-900: #ffffff;
|
||||
--slate-800: #f8fafc;
|
||||
--slate-700: #f1f5f9;
|
||||
--slate-600: #e2e8f0;
|
||||
--slate-500: #cbd5e1;
|
||||
--slate-400: #64748b;
|
||||
/* Keep 400 slightly dark so it stays readable */
|
||||
--slate-300: #475569;
|
||||
--slate-200: #334155;
|
||||
--slate-100: #1e293b;
|
||||
--slate-50: #0f172a;
|
||||
|
||||
/* Enhance contrast for colored text and backgrounds in Light Mode */
|
||||
|
||||
/* RED */
|
||||
--color-red-300: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-400: #dc2626;
|
||||
/* red-600 */
|
||||
--color-red-500: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-950: #ef4444;
|
||||
/* red-500 (so that 950/20 becomes a visible pink bg) */
|
||||
|
||||
/* ORANGE */
|
||||
--color-orange-300: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-400: #ea580c;
|
||||
/* orange-600 */
|
||||
--color-orange-500: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-950: #f97316;
|
||||
/* orange-500 */
|
||||
|
||||
/* YELLOW */
|
||||
--color-yellow-300: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-400: #ca8a04;
|
||||
/* yellow-600 */
|
||||
--color-yellow-500: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-950: #eab308;
|
||||
/* yellow-500 */
|
||||
|
||||
/* GREEN */
|
||||
--color-green-300: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-400: #16a34a;
|
||||
/* green-600 */
|
||||
--color-green-500: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-950: #22c55e;
|
||||
/* green-500 */
|
||||
|
||||
/* EMERALD */
|
||||
--color-emerald-300: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-400: #059669;
|
||||
/* emerald-600 */
|
||||
--color-emerald-500: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-950: #10b981;
|
||||
/* emerald-500 */
|
||||
|
||||
/* BLUE */
|
||||
--color-blue-300: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-400: #2563eb;
|
||||
/* blue-600 */
|
||||
--color-blue-500: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-950: #3b82f6;
|
||||
/* blue-500 */
|
||||
|
||||
/* PURPLE */
|
||||
--color-purple-300: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-400: #9333ea;
|
||||
/* purple-600 */
|
||||
--color-purple-500: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-950: #a855f7;
|
||||
/* purple-500 */
|
||||
/* ── Zero Horizontal Scrollbar – Global Enforcement ── */
|
||||
/* Per AGENTS.md §9: horizontal scrollbar strictly prohibited at any level. */
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--foreground);
|
||||
/* Optical sizing + smoothing */
|
||||
font-optical-sizing: auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* 1-Second Dual-Layer Smooth Background Crossfade */
|
||||
|
||||
:root {
|
||||
/* Premium Dark Theme */
|
||||
--background: #09090b;
|
||||
/* Very dark zinc */
|
||||
--foreground: #fafafa;
|
||||
|
||||
--card: rgba(24, 24, 27, 0.7);
|
||||
/* Zinc 900 with transparency for glassmorphism */
|
||||
--card-foreground: #fafafa;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--primary: #3b82f6;
|
||||
/* Blue 500 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(39, 39, 42, 0.8);
|
||||
/* Zinc 800 */
|
||||
--secondary-foreground: #fafafa;
|
||||
|
||||
--muted: rgba(39, 39, 42, 0.5);
|
||||
--muted-foreground: #a1a1aa;
|
||||
/* Zinc 400 */
|
||||
|
||||
--destructive: #ef4444;
|
||||
/* Red 500 */
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--sidebar: #09090b;
|
||||
|
||||
/* Overrides for hardcoded colors in Dark Mode (Normal) */
|
||||
--t-white: #ffffff;
|
||||
--t-black: #000000;
|
||||
--slate-900: #0f172a;
|
||||
--slate-800: #1e293b;
|
||||
--slate-700: #334155;
|
||||
--slate-600: #475569;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #94a3b8;
|
||||
--slate-300: #cbd5e1;
|
||||
--slate-200: #e2e8f0;
|
||||
--slate-100: #f1f5f9;
|
||||
--slate-50: #f8fafc;
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Clean Light Theme */
|
||||
--background: #f1f5f9;
|
||||
/* Slate 100 */
|
||||
--foreground: #0f172a;
|
||||
|
||||
--card: rgba(255, 255, 255, 0.9);
|
||||
--card-foreground: #0f172a;
|
||||
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
|
||||
--primary: #2563eb;
|
||||
/* Blue 600 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(241, 245, 249, 0.8);
|
||||
/* Slate 100 */
|
||||
--secondary-foreground: #0f172a;
|
||||
|
||||
--muted: rgba(226, 232, 240, 0.5);
|
||||
/* Slate 200 */
|
||||
--muted-foreground: #64748b;
|
||||
/* Slate 500 */
|
||||
|
||||
--sidebar: #ffffff;
|
||||
|
||||
/* Overrides for hardcoded colors in Light Mode (Inverted) */
|
||||
--t-white: #0f172a;
|
||||
/* Make text-white become very dark slate */
|
||||
--t-black: #ffffff;
|
||||
--slate-900: #ffffff;
|
||||
--slate-800: #f8fafc;
|
||||
--slate-700: #f1f5f9;
|
||||
--slate-600: #e2e8f0;
|
||||
--slate-500: #cbd5e1;
|
||||
--slate-400: #64748b;
|
||||
/* Keep 400 slightly dark so it stays readable */
|
||||
--slate-300: #475569;
|
||||
--slate-200: #334155;
|
||||
--slate-100: #1e293b;
|
||||
--slate-50: #0f172a;
|
||||
|
||||
/* Enhance contrast for colored text and backgrounds in Light Mode */
|
||||
|
||||
/* RED */
|
||||
--color-red-300: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-400: #dc2626;
|
||||
/* red-600 */
|
||||
--color-red-500: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-950: #ef4444;
|
||||
/* red-500 (so that 950/20 becomes a visible pink bg) */
|
||||
|
||||
/* ORANGE */
|
||||
--color-orange-300: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-400: #ea580c;
|
||||
/* orange-600 */
|
||||
--color-orange-500: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-950: #f97316;
|
||||
/* orange-500 */
|
||||
|
||||
/* YELLOW */
|
||||
--color-yellow-300: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-400: #ca8a04;
|
||||
/* yellow-600 */
|
||||
--color-yellow-500: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-950: #eab308;
|
||||
/* yellow-500 */
|
||||
|
||||
/* GREEN */
|
||||
--color-green-300: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-400: #16a34a;
|
||||
/* green-600 */
|
||||
--color-green-500: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-950: #22c55e;
|
||||
/* green-500 */
|
||||
|
||||
/* EMERALD */
|
||||
--color-emerald-300: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-400: #059669;
|
||||
/* emerald-600 */
|
||||
--color-emerald-500: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-950: #10b981;
|
||||
/* emerald-500 */
|
||||
|
||||
/* BLUE */
|
||||
--color-blue-300: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-400: #2563eb;
|
||||
/* blue-600 */
|
||||
--color-blue-500: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-950: #3b82f6;
|
||||
/* blue-500 */
|
||||
|
||||
/* PURPLE */
|
||||
--color-purple-300: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-400: #9333ea;
|
||||
/* purple-600 */
|
||||
--color-purple-500: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-950: #a855f7;
|
||||
/* purple-500 */
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans), sans-serif;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* 1-Second Dual-Layer Smooth Background Crossfade */
|
||||
/* Dark Mode Background Layer (Active by default, fades out on .light) */
|
||||
body::before,
|
||||
body::after {
|
||||
content: "";
|
||||
@@ -426,7 +38,6 @@ body::after {
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Dark Mode Background Layer (Active by default, fades out on .light) */
|
||||
body::before {
|
||||
background-color: #09090b;
|
||||
background-image:
|
||||
@@ -570,7 +181,6 @@ input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
/* Accordion Animation Fix */
|
||||
.accordion-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -2,7 +2,7 @@ import HelpPageContent from "@/components/help/HelpPageContent";
|
||||
|
||||
export const metadata = {
|
||||
title: "User Guide — Deep Package Inspection",
|
||||
description: "Panduan lengkap cara membaca data di setiap halaman dashboard DPI.",
|
||||
description: "Comprehensive guide on reading data across every DPI dashboard page.",
|
||||
};
|
||||
|
||||
export default function HelpRoute() {
|
||||
|
||||
+115
-116
@@ -1,116 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lock, User, Eye, EyeOff } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Login failed");
|
||||
}
|
||||
|
||||
router.push("/");
|
||||
router.refresh(); // Refresh to update middleware state
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col justify-center items-center relative overflow-hidden">
|
||||
{/* Background Glows */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-red-600/20 blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-700/20 blur-[120px] pointer-events-none" />
|
||||
|
||||
<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.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 text-center font-bold font-backone text-white tracking-normal leading-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||
BackOne Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-500 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-white transition-colors focus:outline-none"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 bg-gradient-to-r from-red-600 to-blue-700 hover:from-red-500 hover:to-blue-600 text-white font-medium rounded-lg shadow-[0_0_15px_rgba(220,38,38,0.3)] hover:shadow-[0_0_20px_rgba(220,38,38,0.5)] transition-all duration-300 flex items-center justify-center mt-6"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lock, User, Eye, EyeOff } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Login failed");
|
||||
}
|
||||
|
||||
window.location.href = "/";
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col justify-center items-center relative overflow-hidden">
|
||||
{/* Background Glows */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-red-600/20 blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-700/20 blur-[120px] pointer-events-none" />
|
||||
|
||||
<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.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 text-center font-bold font-backone text-white tracking-normal leading-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||
BackOne Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-500 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-white transition-colors focus:outline-none"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 bg-gradient-to-r from-red-600 to-blue-700 hover:from-red-500 hover:to-blue-600 text-white font-medium rounded-lg shadow-[0_0_15px_rgba(220,38,38,0.3)] hover:shadow-[0_0_20px_rgba(220,38,38,0.5)] transition-all duration-300 flex items-center justify-center mt-6"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--font-backone: var(--font-backone);
|
||||
|
||||
/* Set Inter/sans-serif as the default globally */
|
||||
--font-sans: var(--font-inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
|
||||
|
||||
|
||||
/* Mono also uses Inter first — matches demoplace (--default-mono-font-family: var(--font-inter)) */
|
||||
--font-mono: var(--font-inter, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
|
||||
/* OVERRIDE TAILWIND HARDCODED COLORS FOR SEAMLESS LIGHT MODE */
|
||||
--color-slate-900: var(--slate-900);
|
||||
--color-slate-800: var(--slate-800);
|
||||
--color-slate-700: var(--slate-700);
|
||||
--color-slate-600: var(--slate-600);
|
||||
--color-slate-500: var(--slate-500);
|
||||
--color-slate-400: var(--slate-400);
|
||||
--color-slate-300: var(--slate-300);
|
||||
--color-slate-200: var(--slate-200);
|
||||
--color-slate-100: var(--slate-100);
|
||||
--color-slate-50: var(--slate-50);
|
||||
|
||||
--color-gray-900: var(--slate-900);
|
||||
--color-gray-800: var(--slate-800);
|
||||
--color-gray-700: var(--slate-700);
|
||||
--color-gray-600: var(--slate-600);
|
||||
--color-gray-500: var(--slate-500);
|
||||
--color-gray-400: var(--slate-400);
|
||||
--color-gray-300: var(--slate-300);
|
||||
--color-gray-200: var(--slate-200);
|
||||
--color-gray-100: var(--slate-100);
|
||||
--color-gray-50: var(--slate-50);
|
||||
|
||||
--color-white: var(--t-white);
|
||||
--color-black: var(--t-black);
|
||||
|
||||
/* Radix-style color palette — matching demoplace.my.id exactly */
|
||||
--color-red-50: #fef2f2;
|
||||
--color-red-100: #ffe2e2;
|
||||
--color-red-200: #ffcaca;
|
||||
--color-red-300: #ffa3a3;
|
||||
--color-red-400: #ff6568;
|
||||
--color-red-500: #fb2c36;
|
||||
--color-red-600: #e40014;
|
||||
--color-red-700: #bf000f;
|
||||
--color-red-800: #9f0712;
|
||||
--color-red-900: #82181a;
|
||||
--color-red-950: #460809;
|
||||
|
||||
--color-orange-300: #ffb96d;
|
||||
--color-orange-400: #ff8b1a;
|
||||
--color-orange-500: #fe6e00;
|
||||
--color-orange-900: #7e2a0c;
|
||||
--color-orange-950: #441306;
|
||||
|
||||
--color-amber-100: #fef3c6;
|
||||
--color-amber-200: #fee685;
|
||||
--color-amber-300: #ffd236;
|
||||
--color-amber-400: #fcbb00;
|
||||
--color-amber-500: #f99c00;
|
||||
--color-amber-600: #dd7400;
|
||||
--color-amber-700: #b75000;
|
||||
--color-amber-800: #953d00;
|
||||
--color-amber-900: #7b3306;
|
||||
--color-amber-950: #461901;
|
||||
|
||||
--color-yellow-300: #ffe02a;
|
||||
--color-yellow-400: #fac800;
|
||||
--color-yellow-500: #edb200;
|
||||
|
||||
--color-green-300: #7bf1a8;
|
||||
--color-green-400: #05df72;
|
||||
--color-green-500: #00c758;
|
||||
--color-green-800: #016630;
|
||||
--color-green-900: #0d542b;
|
||||
--color-green-950: #032e15;
|
||||
|
||||
--color-emerald-100: #d0fae5;
|
||||
--color-emerald-200: #a4f4cf;
|
||||
--color-emerald-300: #5ee9b5;
|
||||
--color-emerald-400: #00d294;
|
||||
--color-emerald-500: #00bb7f;
|
||||
--color-emerald-600: #009767;
|
||||
--color-emerald-700: #007956;
|
||||
--color-emerald-900: #004e3b;
|
||||
--color-emerald-950: #002c22;
|
||||
|
||||
--color-teal-400: #00d3bd;
|
||||
--color-teal-500: #00baa7;
|
||||
|
||||
--color-cyan-100: #cefafe;
|
||||
--color-cyan-300: #53eafd;
|
||||
--color-cyan-400: #00d2ef;
|
||||
--color-cyan-500: #00b7d7;
|
||||
--color-cyan-600: #0092b5;
|
||||
|
||||
--color-sky-400: #00bcfe;
|
||||
|
||||
--color-blue-50: #eff6ff;
|
||||
--color-blue-200: #bedbff;
|
||||
--color-blue-300: #90c5ff;
|
||||
--color-blue-400: #54a2ff;
|
||||
--color-blue-500: #3080ff;
|
||||
--color-blue-600: #155dfc;
|
||||
--color-blue-700: #1447e6;
|
||||
--color-blue-800: #193cb8;
|
||||
--color-blue-900: #1c398e;
|
||||
--color-blue-950: #162456;
|
||||
|
||||
--color-indigo-300: #a4b3ff;
|
||||
--color-indigo-400: #7d87ff;
|
||||
--color-indigo-500: #625fff;
|
||||
--color-indigo-600: #4f39f6;
|
||||
--color-indigo-900: #312c85;
|
||||
|
||||
--color-violet-400: #a685ff;
|
||||
--color-violet-500: #8d54ff;
|
||||
|
||||
--color-purple-300: #d9b3ff;
|
||||
--color-purple-400: #c07eff;
|
||||
--color-purple-500: #ac4bff;
|
||||
--color-purple-600: #9810fa;
|
||||
--color-purple-900: #59168b;
|
||||
--color-purple-950: #3c0366;
|
||||
|
||||
--color-pink-400: #fb64b6;
|
||||
--color-rose-400: #ff667f;
|
||||
--color-rose-900: #8b0836;
|
||||
|
||||
/* Micro-animations */
|
||||
--animate-fade-in: fade-in 0.5s ease-out forwards;
|
||||
--animate-slide-up: slide-up 0.5s ease-out forwards;
|
||||
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
:root {
|
||||
/* Premium Dark Theme */
|
||||
--background: #09090b;
|
||||
/* Very dark zinc */
|
||||
--foreground: #fafafa;
|
||||
|
||||
--card: rgba(24, 24, 27, 0.7);
|
||||
/* Zinc 900 with transparency for glassmorphism */
|
||||
--card-foreground: #fafafa;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--primary: #3b82f6;
|
||||
/* Blue 500 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(39, 39, 42, 0.8);
|
||||
/* Zinc 800 */
|
||||
--secondary-foreground: #fafafa;
|
||||
|
||||
--muted: rgba(39, 39, 42, 0.5);
|
||||
--muted-foreground: #a1a1aa;
|
||||
/* Zinc 400 */
|
||||
|
||||
--destructive: #ef4444;
|
||||
/* Red 500 */
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--sidebar: #09090b;
|
||||
|
||||
/* Overrides for hardcoded colors in Dark Mode (Normal) */
|
||||
--t-white: #ffffff;
|
||||
--t-black: #000000;
|
||||
--slate-900: #0f172a;
|
||||
--slate-800: #1e293b;
|
||||
--slate-700: #334155;
|
||||
--slate-600: #475569;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #94a3b8;
|
||||
--slate-300: #cbd5e1;
|
||||
--slate-200: #e2e8f0;
|
||||
--slate-100: #f1f5f9;
|
||||
--slate-50: #f8fafc;
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Clean Light Theme */
|
||||
--background: #f1f5f9;
|
||||
/* Slate 100 */
|
||||
--foreground: #0f172a;
|
||||
|
||||
--card: rgba(255, 255, 255, 0.9);
|
||||
--card-foreground: #0f172a;
|
||||
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
|
||||
--primary: #2563eb;
|
||||
/* Blue 600 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(241, 245, 249, 0.8);
|
||||
/* Slate 100 */
|
||||
--secondary-foreground: #0f172a;
|
||||
|
||||
--muted: rgba(226, 232, 240, 0.5);
|
||||
/* Slate 200 */
|
||||
--muted-foreground: #64748b;
|
||||
/* Slate 500 */
|
||||
|
||||
--sidebar: #ffffff;
|
||||
|
||||
/* Overrides for hardcoded colors in Light Mode (Inverted) */
|
||||
--t-white: #0f172a;
|
||||
/* Make text-white become very dark slate */
|
||||
--t-black: #ffffff;
|
||||
--slate-900: #ffffff;
|
||||
--slate-800: #f8fafc;
|
||||
--slate-700: #f1f5f9;
|
||||
--slate-600: #e2e8f0;
|
||||
--slate-500: #cbd5e1;
|
||||
--slate-400: #64748b;
|
||||
/* Keep 400 slightly dark so it stays readable */
|
||||
--slate-300: #475569;
|
||||
--slate-200: #334155;
|
||||
--slate-100: #1e293b;
|
||||
--slate-50: #0f172a;
|
||||
|
||||
/* Enhance contrast for colored text and backgrounds in Light Mode */
|
||||
|
||||
/* RED */
|
||||
--color-red-300: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-400: #dc2626;
|
||||
/* red-600 */
|
||||
--color-red-500: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-950: #ef4444;
|
||||
/* red-500 (so that 950/20 becomes a visible pink bg) */
|
||||
|
||||
/* ORANGE */
|
||||
--color-orange-300: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-400: #ea580c;
|
||||
/* orange-600 */
|
||||
--color-orange-500: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-950: #f97316;
|
||||
/* orange-500 */
|
||||
|
||||
/* YELLOW */
|
||||
--color-yellow-300: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-400: #ca8a04;
|
||||
/* yellow-600 */
|
||||
--color-yellow-500: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-950: #eab308;
|
||||
/* yellow-500 */
|
||||
|
||||
/* GREEN */
|
||||
--color-green-300: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-400: #16a34a;
|
||||
/* green-600 */
|
||||
--color-green-500: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-950: #22c55e;
|
||||
/* green-500 */
|
||||
|
||||
/* EMERALD */
|
||||
--color-emerald-300: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-400: #059669;
|
||||
/* emerald-600 */
|
||||
--color-emerald-500: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-950: #10b981;
|
||||
/* emerald-500 */
|
||||
|
||||
/* BLUE */
|
||||
--color-blue-300: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-400: #2563eb;
|
||||
/* blue-600 */
|
||||
--color-blue-500: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-950: #3b82f6;
|
||||
/* blue-500 */
|
||||
|
||||
/* PURPLE */
|
||||
--color-purple-300: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-400: #9333ea;
|
||||
/* purple-600 */
|
||||
--color-purple-500: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-950: #a855f7;
|
||||
/* purple-500 */
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -265,5 +265,5 @@ export default function AgentAccountModal({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
interface AgentChecklistProps {
|
||||
availableAgents: string[];
|
||||
selectedAgents: string[];
|
||||
onAgentCheck: (uuid: string, checked: boolean) => void;
|
||||
agentNamesMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function AgentChecklist({
|
||||
availableAgents,
|
||||
selectedAgents,
|
||||
onAgentCheck,
|
||||
agentNamesMap,
|
||||
}: AgentChecklistProps) {
|
||||
const filteredAgents = availableAgents
|
||||
.filter(Boolean)
|
||||
.filter((uuid) => uuid.trim() !== "");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-2">
|
||||
🎯 Assign Network Agents ({selectedAgents.length} selected)
|
||||
</label>
|
||||
<div className="w-full max-h-40 overflow-y-auto bg-slate-800/40 border border-slate-700 rounded-lg p-3 space-y-2.5 custom-scrollbar">
|
||||
{filteredAgents.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 italic">No network agents available</p>
|
||||
) : (
|
||||
filteredAgents.map((uuid) => {
|
||||
const isChecked = selectedAgents.includes(uuid);
|
||||
const agentName = agentNamesMap[uuid] || uuid;
|
||||
return (
|
||||
<label
|
||||
key={uuid}
|
||||
className="flex items-center gap-3 cursor-pointer group text-xs text-slate-300 select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => onAgentCheck(uuid, e.target.checked)}
|
||||
className="rounded border-slate-600 bg-slate-800 text-amber-500 focus:ring-amber-500/50 w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="group-hover:text-white transition-colors">
|
||||
{agentName}
|
||||
</span>
|
||||
{agentName !== uuid && (
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
({uuid})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
export function EmptyMap() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-72 gap-4">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
style={{ background: "rgba(99,102,241,0.1)", border: "1px solid rgba(99,102,241,0.25)" }}>
|
||||
<MapPin className="w-7 h-7 text-indigo-400 opacity-60" />
|
||||
</div>
|
||||
<div className="text-center font-serif">
|
||||
<p className="text-sm font-semibold text-slate-300">No agent locations configured</p>
|
||||
<p className="text-xs text-slate-400 mt-2.5 max-w-sm leading-relaxed flex items-center justify-center gap-1.5 flex-wrap">
|
||||
<span>Click the</span>
|
||||
<span className="inline-flex items-center justify-center p-1 rounded bg-indigo-950/20 text-slate-500 border border-slate-700/30" title="Map Pin Icon">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-500" />
|
||||
</span>
|
||||
<span>icon in the</span>
|
||||
<span className="text-slate-300 font-semibold">Actions</span>
|
||||
<span>column to set coordinates.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// src/components/admin/AgentLocationMap.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import {
|
||||
EmptyMap,
|
||||
MAP_CSS,
|
||||
drawFlowLines,
|
||||
drawMarkers,
|
||||
type AgentLocationPin
|
||||
} from "./AgentLocationMapHelpers";
|
||||
|
||||
interface Props {
|
||||
pins: AgentLocationPin[];
|
||||
onEditLocation?: (agentUuid: string, agentLabel: string) => void;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
const AgentLocationMap = React.memo(function AgentLocationMap({
|
||||
pins,
|
||||
onEditLocation,
|
||||
canEdit = false,
|
||||
}: Props) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRef = useRef<any>(null);
|
||||
const LRef = useRef<any>(null);
|
||||
const [flows, setFlows] = React.useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch("/api/dashboard/agent-flows", { headers: getViewAsHeaders() })
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
if (active && json.ok && json.data) {
|
||||
setFlows(json.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Failed to load agent flows for map:", err));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [pins]);
|
||||
|
||||
const resetView = () => {
|
||||
if (!instanceRef.current || !pins.length || !LRef.current) return;
|
||||
const L = LRef.current;
|
||||
if (pins.length === 1) {
|
||||
instanceRef.current.setView([pins[0].latitude, pins[0].longitude], 14, { animate: false });
|
||||
} else {
|
||||
instanceRef.current.fitBounds(L.latLngBounds(pins.map((p) => [p.latitude, p.longitude])), {
|
||||
padding: [70, 70],
|
||||
maxZoom: 14,
|
||||
animate: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
if (!mapRef.current) return;
|
||||
|
||||
// Inject styles
|
||||
const styleId = "leaflet-custom-markers-css";
|
||||
if (!document.getElementById(styleId)) {
|
||||
const s = document.createElement("style");
|
||||
s.id = styleId;
|
||||
s.innerHTML = MAP_CSS;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const boot = async () => {
|
||||
if (!mounted || !mapRef.current) return;
|
||||
|
||||
// Inject Leaflet CSS
|
||||
if (!document.getElementById("lf-css-admin")) {
|
||||
const lnk = document.createElement("link");
|
||||
lnk.id = "lf-css-admin";
|
||||
lnk.rel = "stylesheet";
|
||||
lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(lnk);
|
||||
}
|
||||
|
||||
const L = (await import("leaflet")).default;
|
||||
if (!mounted || !mapRef.current) return;
|
||||
LRef.current = L;
|
||||
|
||||
if (instanceRef.current) {
|
||||
try {
|
||||
instanceRef.current.remove();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const map = L.map(mapRef.current, {
|
||||
center: pins[0] ? [pins[0].latitude, pins[0].longitude] : [-2.548926, 118.0148634],
|
||||
zoom: pins.length === 1 ? 14 : 8,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
minZoom: 2,
|
||||
maxZoom: 20,
|
||||
maxBounds: [[-90, -180], [90, 180]],
|
||||
maxBoundsViscosity: 1.0,
|
||||
zoomAnimation: false,
|
||||
fadeAnimation: false,
|
||||
markerZoomAnimation: false,
|
||||
});
|
||||
instanceRef.current = map;
|
||||
|
||||
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
|
||||
subdomains: "abcd",
|
||||
maxZoom: 20,
|
||||
noWrap: true,
|
||||
}).addTo(map);
|
||||
|
||||
if (pins.length > 1) {
|
||||
map.fitBounds(L.latLngBounds(pins.map((p) => [p.latitude, p.longitude])), {
|
||||
padding: [70, 70],
|
||||
maxZoom: 14,
|
||||
animate: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Get heartbeats for agent uptime from window or fetch ───────────────
|
||||
const uptimeMap: Record<string, number> = {};
|
||||
try {
|
||||
const res = await fetch("/api/dashboard/agent-heartbeats");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.ok && json.data) {
|
||||
json.data.forEach((item: any) => {
|
||||
uptimeMap[item.agent_uuid] = item.uptime_percent ?? 100;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Draw Flow lines
|
||||
drawFlowLines(map, L, pins, flows);
|
||||
|
||||
// Draw Markers
|
||||
drawMarkers(map, L, pins, uptimeMap, canEdit, onEditLocation);
|
||||
};
|
||||
|
||||
boot();
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (instanceRef.current) {
|
||||
try {
|
||||
instanceRef.current.remove();
|
||||
} catch (_) {}
|
||||
instanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pins, flows, canEdit]);
|
||||
|
||||
if (!pins.length) return <EmptyMap />;
|
||||
|
||||
return (
|
||||
<div className="relative w-full rounded-xl overflow-hidden" style={{ height: 420 }}>
|
||||
<div ref={mapRef} style={{ width: "100%", height: "100%", background: "#0a1628" }} />
|
||||
|
||||
{/* Controls */}
|
||||
<div className="absolute top-3 right-3 z-[1000] flex flex-col gap-1.5">
|
||||
{[
|
||||
{ t: "Zoom In", l: "+", fn: () => instanceRef.current?.zoomIn() },
|
||||
{ t: "Zoom Out", l: "−", fn: () => instanceRef.current?.zoomOut() },
|
||||
].map((b) => (
|
||||
<button
|
||||
key={b.t}
|
||||
onClick={b.fn}
|
||||
title={b.t}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg font-bold text-xl text-white hover:bg-indigo-600/30 transition-colors"
|
||||
style={{
|
||||
background: "rgba(13,21,38,0.92)",
|
||||
border: "1px solid rgba(99,102,241,0.35)",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
{b.l}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={resetView}
|
||||
title="Fit all agents"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
style={{
|
||||
background: "rgba(13,21,38,0.92)",
|
||||
border: "1px solid rgba(99,102,241,0.35)",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className="absolute bottom-3 left-3 z-[1000] flex gap-4 text-xs px-3.5 py-2 rounded-xl"
|
||||
style={{
|
||||
background: "rgba(10,17,32,0.88)",
|
||||
border: "1px solid rgba(99,102,241,0.2)",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
["#10b981", "Online", true],
|
||||
["#ef4444", "Offline", false],
|
||||
].map(([c, l, glow]: any) => (
|
||||
<span key={l} className="flex items-center gap-1.5 text-slate-400">
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: c,
|
||||
boxShadow: glow ? `0 0 7px ${c}` : undefined,
|
||||
}}
|
||||
/>
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
{flows.length > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-slate-500">
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 16,
|
||||
height: 2,
|
||||
background: "rgba(99,102,241,0.7)",
|
||||
borderTop: "1px dashed rgba(99,102,241,0.9)",
|
||||
}}
|
||||
/>
|
||||
Traffic flow
|
||||
</span>
|
||||
)}
|
||||
<span className="font-semibold font-mono" style={{ color: "#818cf8" }}>
|
||||
{pins.length} agent{pins.length !== 1 ? "s" : ""} mapped
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AgentLocationMap;
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getFlowTooltipContent } from "../dashboard/IndonesiaAgentMapHelpers";
|
||||
import { EmptyMap } from "./AgentEmptyMap";
|
||||
import { MAP_CSS } from "./AgentMapCss";
|
||||
|
||||
export { EmptyMap, MAP_CSS };
|
||||
|
||||
export interface AgentLocationPin {
|
||||
agent_uuid: string;
|
||||
label: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
isOnline?: boolean;
|
||||
uptimePercent?: number;
|
||||
site_uuid?: string;
|
||||
}
|
||||
|
||||
export function buildMarkerHtml(online: boolean) {
|
||||
return `
|
||||
<div class="custom-agent-marker ${online ? 'online' : 'offline'}">
|
||||
<div class="marker-beacon-ring"></div>
|
||||
<div class="marker-glass-disc">
|
||||
<div class="marker-core-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Draw Flow lines ONLY if they exist in active flows ────────────────
|
||||
export function drawFlowLines(map: any, L: any, pins: AgentLocationPin[], flows: any[]) {
|
||||
flows.forEach((flow) => {
|
||||
const src = pins.find((p) => p.agent_uuid === flow.source);
|
||||
const dst = pins.find((p) => p.agent_uuid === flow.target);
|
||||
if (!src || !dst) return;
|
||||
|
||||
const bothOn = src.isOnline && dst.isOnline;
|
||||
const bothOff = !src.isOnline && !dst.isOnline;
|
||||
const lineColor = bothOn ? "#34d399" : bothOff ? "#f87171" : "#fbbf24";
|
||||
const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.0), 6);
|
||||
|
||||
// Background polyline for hover trigger area
|
||||
const baseLine = L.polyline(
|
||||
[[src.latitude, src.longitude], [dst.latitude, dst.longitude]],
|
||||
{ color: lineColor, weight: weight + 4, opacity: 0.15 }
|
||||
).addTo(map);
|
||||
|
||||
// Main colored line
|
||||
const activeLine = L.polyline(
|
||||
[[src.latitude, src.longitude], [dst.latitude, dst.longitude]],
|
||||
{ color: lineColor, weight: weight, opacity: 0.5 }
|
||||
).addTo(map);
|
||||
|
||||
// Moving animation line
|
||||
const animLine = L.polyline(
|
||||
[[src.latitude, src.longitude], [dst.latitude, dst.longitude]],
|
||||
{
|
||||
color: lineColor,
|
||||
weight: weight * 0.6,
|
||||
opacity: 0.85,
|
||||
dashArray: "8 12",
|
||||
className: "flow-line",
|
||||
}
|
||||
).addTo(map);
|
||||
|
||||
const tooltipHtml = getFlowTooltipContent(
|
||||
src.label,
|
||||
dst.label,
|
||||
flow.bytes,
|
||||
flow.flowsCount,
|
||||
flow.details || []
|
||||
);
|
||||
|
||||
[baseLine, activeLine, animLine].forEach((line) => {
|
||||
line.bindTooltip(tooltipHtml, { sticky: true, className: "leaflet-custom-popup shadow-2xl" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Draw Markers with Popups & Tooltips ───────────────────────────────
|
||||
export function drawMarkers(
|
||||
map: any,
|
||||
L: any,
|
||||
pins: AgentLocationPin[],
|
||||
uptimeMap: Record<string, number>,
|
||||
canEdit: boolean,
|
||||
onEditLocation?: (agentUuid: string, agentLabel: string) => void
|
||||
) {
|
||||
pins.forEach((pin) => {
|
||||
const online = pin.isOnline ?? false;
|
||||
const color = online ? "#10b981" : "#ef4444";
|
||||
const colorL = online ? "#34d399" : "#f87171";
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: buildMarkerHtml(online),
|
||||
className: "",
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
popupAnchor: [0, -18],
|
||||
tooltipAnchor: [0, -18],
|
||||
});
|
||||
|
||||
const marker = L.marker([pin.latitude, pin.longitude], { icon });
|
||||
|
||||
// ── Permanent label tooltip ──────────────────────────────────────────
|
||||
const shortLabel = pin.label.length > 22 ? pin.label.slice(0, 20) + "…" : pin.label;
|
||||
marker.bindTooltip(
|
||||
`<div style="
|
||||
padding:4px 10px 4px 8px; border-radius:8px; white-space:nowrap;
|
||||
background:rgba(10,18,36,0.93); border:1.5px solid ${colorL};
|
||||
color:${colorL}; font-size:11px; font-weight:700;
|
||||
font-family:var(--font-sans),sans-serif; letter-spacing:0.02em;
|
||||
box-shadow:0 0 10px ${color}55, 0 2px 12px rgba(0,0,0,0.7);
|
||||
display:flex; align-items:center; gap:6px;">
|
||||
<span style="width:7px;height:7px;border-radius:50%;background:${color};
|
||||
flex-shrink:0;${online ? `box-shadow:0 0 6px ${color}` : ""};"></span>
|
||||
${shortLabel}
|
||||
</div>`,
|
||||
{ permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false }
|
||||
);
|
||||
|
||||
// ── Click popup ──────────────────────────────────────────────────────
|
||||
const uptime = uptimeMap[pin.agent_uuid];
|
||||
const uptimeColor =
|
||||
(uptime ?? 100) >= 98
|
||||
? "#34d399"
|
||||
: (uptime ?? 100) >= 90
|
||||
? "#fbbf24"
|
||||
: "#f87171";
|
||||
const editBtn =
|
||||
canEdit && onEditLocation
|
||||
? `<button id="el-${pin.agent_uuid.replace(/\W/g, "")}"
|
||||
style="margin-top:10px;width:100%;padding:6px;background:rgba(99,102,241,0.15);
|
||||
border:1px solid rgba(99,102,241,0.35);border-radius:8px;color:#818cf8;
|
||||
font-size:11px;font-weight:700;cursor:pointer;font-family:var(--font-sans),sans-serif;"
|
||||
onmouseover="this.style.background='rgba(99,102,241,0.3)'"
|
||||
onmouseout="this.style.background='rgba(99,102,241,0.15)'">✎ Update Location</button>`
|
||||
: "";
|
||||
|
||||
marker.bindPopup(
|
||||
`<div style="background:#0d1526;border:1px solid ${colorL}44;border-radius:14px;padding:14px 16px;min-width:200px;font-family:var(--font-sans),sans-serif;box-shadow:0 20px 60px rgba(0,0,0,0.85);">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
|
||||
<span style="width:9px;height:9px;border-radius:50%;background:${color};${
|
||||
online ? `box-shadow:0 0 8px ${color};` : ""
|
||||
};flex-shrink:0;"></span>
|
||||
<span style="font-size:13px;font-weight:800;color:${colorL};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:170px;">${
|
||||
pin.label
|
||||
}</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:10.5px;font-family:monospace;">
|
||||
<span style="color:#475569;">UUID</span><span style="color:#93c5fd;">${pin.agent_uuid}</span>
|
||||
<span style="color:#475569;">Status</span><span style="color:${color};font-weight:700;">${
|
||||
online ? "● Online" : "○ Offline"
|
||||
}</span>
|
||||
<span style="color:#475569;">Lat</span><span style="color:#cbd5e1;">${pin.latitude.toFixed(6)}</span>
|
||||
<span style="color:#475569;">Lng</span><span style="color:#cbd5e1;">${pin.longitude.toFixed(6)}</span>
|
||||
${
|
||||
uptime !== undefined
|
||||
? `<span style="color:#475569;">Uptime</span><span style="color:${uptimeColor};font-weight:700;">${uptime.toFixed(
|
||||
1
|
||||
)}%</span>`
|
||||
: ""
|
||||
}
|
||||
</div>${editBtn}
|
||||
</div>`,
|
||||
{ className: "agent-popup", maxWidth: 260 }
|
||||
);
|
||||
|
||||
marker.on("popupopen", () => {
|
||||
const id = `el-${pin.agent_uuid.replace(/\W/g, "")}`;
|
||||
setTimeout(() => {
|
||||
document.getElementById(id)?.addEventListener("click", () => {
|
||||
onEditLocation?.(pin.agent_uuid, pin.label);
|
||||
map.closePopup();
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
|
||||
marker.addTo(map);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export const MAP_CSS = `
|
||||
.leaflet-control-attribution { display:none !important; }
|
||||
.agent-popup .leaflet-popup-content-wrapper { background:#0a1224!important; border:1px solid rgba(99,102,241,0.25)!important; box-shadow:0 12px 36px rgba(0,0,0,0.65)!important; padding:0!important; border-radius:12px!important; }
|
||||
.agent-popup .leaflet-popup-content { margin:0!important; }
|
||||
.agent-popup .leaflet-popup-tip-container { display:none!important; }
|
||||
.agent-popup .leaflet-popup-close-button { color:#94a3b8!important; font-size:18px!important; top:8px!important; right:10px!important; }
|
||||
.agent-label-tooltip { background:transparent!important; border:none!important; box-shadow:none!important; padding:0!important; }
|
||||
.agent-label-tooltip::before { display:none!important; }
|
||||
@keyframes dash-flow { to { stroke-dashoffset:-20; } }
|
||||
.flow-line { animation: dash-flow 1.4s linear infinite; }
|
||||
|
||||
/* Tooltip and popup custom classes */
|
||||
.leaflet-custom-popup .leaflet-popup-content-wrapper {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.leaflet-custom-popup .leaflet-popup-tip {
|
||||
background: #0f172a !important;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
.leaflet-custom-popup .leaflet-popup-content {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Premium Map Marker Styles */
|
||||
.custom-agent-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.marker-beacon-ring {
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.online .marker-beacon-ring {
|
||||
border: 2px solid #10b981;
|
||||
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
.offline .marker-beacon-ring {
|
||||
border: 2px solid #ef4444;
|
||||
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
@keyframes beacon-ping {
|
||||
0% { transform: scale(0.4); opacity: 0.85; }
|
||||
50% { opacity: 0.45; }
|
||||
100% { transform: scale(1.8); opacity: 0; }
|
||||
}
|
||||
.marker-glass-disc {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: rgba(10, 18, 36, 0.85);
|
||||
border: 2px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.15);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.online .marker-glass-disc {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.offline .marker-glass-disc {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.marker-core-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.online .marker-core-dot {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px #10b981;
|
||||
}
|
||||
.offline .marker-core-dot {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 6px #ef4444;
|
||||
}
|
||||
.custom-agent-marker:hover .marker-glass-disc {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
`;
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useState } from "react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { createAgent, deleteAgent, Agent } from "@/lib/actions/agents";
|
||||
import { createAgent, deleteAgent } from "@/lib/actions/agents";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
|
||||
import AgentAccountModal from "@/components/admin/AgentAccountModal";
|
||||
import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { Loader2, Monitor, Shield, Tag, Cpu, Clock, Network } from "lucide-react";
|
||||
|
||||
interface IpHistoryItem {
|
||||
ip_address: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flows: number;
|
||||
}
|
||||
|
||||
interface DeviceMacDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
macAddress: string;
|
||||
customLabel: string | null;
|
||||
defaultLabel: string;
|
||||
agentName: string;
|
||||
manufacturer: string;
|
||||
deviceType: string;
|
||||
}
|
||||
|
||||
export default function DeviceMacDetailsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
macAddress,
|
||||
customLabel,
|
||||
defaultLabel,
|
||||
agentName,
|
||||
manufacturer,
|
||||
deviceType,
|
||||
}: DeviceMacDetailsModalProps) {
|
||||
const [ips, setIps] = useState<IpHistoryItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !macAddress) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetch(`/api/dashboard/devices/mac-details?mac=${encodeURIComponent(macAddress)}`, {
|
||||
headers: getViewAsHeaders(),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.ok) {
|
||||
setIps(data.ips || []);
|
||||
} else {
|
||||
setError(data.error || "Failed to load device details.");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message || "An error occurred while loading details.");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [isOpen, macAddress]);
|
||||
|
||||
const formatFriendlyDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="🔍 Device Network Details"
|
||||
>
|
||||
<div className="space-y-6 max-h-[75vh] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{/* Profile Card */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Tag className="w-3 h-3" /> MAC Address
|
||||
</span>
|
||||
<code className="text-xs text-white font-mono block">{macAddress}</code>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Cpu className="w-3 h-3" /> Manufacturer
|
||||
</span>
|
||||
<span className="text-xs text-slate-200 block truncate" title={manufacturer}>
|
||||
{manufacturer || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1 col-span-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Monitor className="w-3 h-3" /> Device Labels
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-slate-500 font-medium">Custom:</span>
|
||||
{customLabel ? (
|
||||
<span className="text-xs font-semibold text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2 py-0.5 rounded-full">
|
||||
{customLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic">No label configured</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-slate-500 font-medium">System:</span>
|
||||
<span className="text-xs text-slate-400 truncate">{defaultLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" /> Device Type
|
||||
</span>
|
||||
<span className="text-xs text-slate-200 block capitalize">{deviceType || "Unknown"}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Network className="w-3 h-3" /> Network Agent
|
||||
</span>
|
||||
<span className="text-xs text-purple-400 font-semibold block truncate" title={agentName}>
|
||||
{agentName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IPs Section */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center gap-2">
|
||||
🔗 Associated IP Addresses
|
||||
</h4>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 flex justify-center items-center gap-2 text-slate-400 text-xs">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
Resolving active IP address histories...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-xs">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
) : ips.length === 0 ? (
|
||||
<div className="py-8 text-center text-slate-500 italic text-xs border border-white/5 rounded-xl bg-white/[0.01]">
|
||||
No IP addresses recorded for this MAC address.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-white/5 rounded-xl overflow-hidden bg-white/[0.01]">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
<table className="w-full text-xs text-slate-300">
|
||||
<thead className="bg-white/[0.03] text-slate-400 uppercase text-[9px] tracking-wider sticky top-0 z-10 border-b border-white/5">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-center">IP Address</th>
|
||||
<th className="px-3 py-2 text-center">First Seen</th>
|
||||
<th className="px-3 py-2 text-center">Last Seen</th>
|
||||
<th className="px-3 py-2 text-center">Traffic (Down / Up)</th>
|
||||
<th className="px-3 py-2 text-center">Flows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{ips.map((ip) => (
|
||||
<tr key={ip.ip_address} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono text-center text-slate-200">
|
||||
{ip.ip_address}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-slate-400">
|
||||
{formatFriendlyDate(ip.first_seen)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-slate-400 flex items-center justify-center gap-1">
|
||||
<Clock className="w-3 h-3 text-slate-500" />
|
||||
{formatFriendlyDate(ip.last_seen)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono">
|
||||
{fmtBytes(ip.download)} / {fmtBytes(ip.upload)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono text-slate-400">
|
||||
{ip.flows}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg text-xs font-semibold transition-all"
|
||||
>
|
||||
Close Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, User, Key, Tag, Upload, Loader2, CheckCircle2, AlertCircle, Trash2, Shield } from "lucide-react";
|
||||
import { X, User, Key, Tag, Loader2, AlertCircle, CheckCircle2, Trash2, Shield } from "lucide-react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { updateAdminAgentUser, deleteAdminAgentUser, createAdminExternalUser } from "@/lib/admin-api";
|
||||
import { useExternalAccountForm } from "./useExternalAccountForm";
|
||||
import RoleSelect from "./RoleSelect";
|
||||
import AgentChecklist from "./AgentChecklist";
|
||||
import ProfilePictureInput from "./ProfilePictureInput";
|
||||
|
||||
interface ExternalAccountModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -13,6 +15,8 @@ interface ExternalAccountModalProps {
|
||||
user?: ManagedUser | null;
|
||||
targetSiteUuid?: string | null;
|
||||
targetCreatedBy?: string | null;
|
||||
targetCompany?: string | null;
|
||||
modalContext?: 'agents' | 'user-accounts';
|
||||
}
|
||||
|
||||
export default function ExternalAccountModal({
|
||||
@@ -22,107 +26,41 @@ export default function ExternalAccountModal({
|
||||
user,
|
||||
targetSiteUuid,
|
||||
targetCreatedBy,
|
||||
targetCompany,
|
||||
modalContext = 'user-accounts',
|
||||
}: ExternalAccountModalProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [accountName, setAccountName] = useState("");
|
||||
const [role, setRole] = useState("SOC_ANALYST");
|
||||
const [previewPic, setPreviewPic] = useState<string | null>(null);
|
||||
const [picFile, setPicFile] = useState<File | null>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setPicFile(null);
|
||||
if (user) {
|
||||
setUsername(user.username);
|
||||
setAccountName(user.account_name || "");
|
||||
setRole(user.role);
|
||||
setPassword(""); // Selalu kosong agar admin bisa memasukkan password baru untuk di-reset
|
||||
setPreviewPic(
|
||||
user.profile_picture
|
||||
? `/api/auth/uploads/${user.profile_picture}`
|
||||
: null
|
||||
);
|
||||
} else {
|
||||
setUsername("");
|
||||
setAccountName("");
|
||||
setRole("SOC_ANALYST");
|
||||
setPassword("");
|
||||
setPreviewPic(null);
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
const handlePicChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setPicFile(file);
|
||||
setPreviewPic(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isEdit = !!user;
|
||||
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (username.trim()) fd.append("username", username.trim());
|
||||
if (password) fd.append("password", password);
|
||||
fd.append("account_name", accountName.trim());
|
||||
if (picFile) fd.append("profile_picture", picFile);
|
||||
|
||||
if (isEdit) {
|
||||
fd.append("user_id", String(user.id));
|
||||
await updateAdminAgentUser(fd);
|
||||
setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!");
|
||||
} else {
|
||||
fd.append("role", role);
|
||||
if (targetSiteUuid) fd.append("site_uuid", targetSiteUuid);
|
||||
if (targetCreatedBy) fd.append("created_by", targetCreatedBy);
|
||||
await createAdminExternalUser(fd);
|
||||
setSuccess("External account successfully created!");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!user) return;
|
||||
if (!confirm(`Delete account "${user.username}"? This action cannot be undone.`)) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAdminAgentUser(user.id);
|
||||
setSuccess("Account successfully deleted!");
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
const {
|
||||
adminRole,
|
||||
username,
|
||||
setUsername,
|
||||
password,
|
||||
setPassword,
|
||||
accountName,
|
||||
setAccountName,
|
||||
role,
|
||||
setRole,
|
||||
companyName,
|
||||
setCompanyName,
|
||||
selectedAgents,
|
||||
previewPic,
|
||||
isSubmitting,
|
||||
isDeleting,
|
||||
success,
|
||||
error,
|
||||
availableAgents,
|
||||
agentNamesMap,
|
||||
handlePicChange,
|
||||
handleAgentCheck,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
} = useExternalAccountForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
user,
|
||||
targetCompany,
|
||||
modalContext,
|
||||
});
|
||||
|
||||
const isEdit = !!user;
|
||||
|
||||
@@ -130,7 +68,6 @@ export default function ExternalAccountModal({
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -140,164 +77,190 @@ export default function ExternalAccountModal({
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden"
|
||||
className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-amber-900/40 to-slate-900">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{isEdit ? "Manage External Account" : "Create External Account"}
|
||||
</h3>
|
||||
{isEdit && <p className="text-xs text-slate-400 mt-0.5 font-mono">Role: <span className="font-semibold text-amber-400">{user.role.replace('_', ' ')}</span></p>}
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{isEdit && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 text-xs text-amber-200/80 mb-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<p><strong>Password Recovery:</strong> For security reasons, original passwords cannot be viewed. To recover an account, enter a new password below to reset it.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Picture */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full border-2 border-dashed border-slate-600 flex items-center justify-center cursor-pointer hover:border-amber-500 transition-colors overflow-hidden bg-slate-800"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{previewPic ? (
|
||||
<img src={previewPic} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">Profile Picture</p>
|
||||
<p className="text-xs text-slate-500">Click to upload image</p>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePicChange} />
|
||||
</div>
|
||||
|
||||
{/* Account Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={e => setAccountName(e.target.value)}
|
||||
placeholder="Full Name"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role (Only for Create mode) */}
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Shield className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Role
|
||||
</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={e => setRole(e.target.value)}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
>
|
||||
<option value="SOC_ANALYST">SOC Analyst</option>
|
||||
<option value="ENGINEER">Engineer</option>
|
||||
<option value="TENANT_ADMIN">Tenant Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Login Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Username for login"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Reset / Create */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
{isEdit ? "Reset Password" : "Password"} {isEdit && <span className="text-slate-500">(leave blank to keep unchanged)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "Type new password to override" : "Create password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-900/30 border border-green-800 text-green-300 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isEdit && (
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-amber-900/40 to-slate-900 shrink-0">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{isEdit
|
||||
? "Manage Account"
|
||||
: modalContext === 'agents'
|
||||
? "Create Technical Account"
|
||||
: "Create Company Account"}
|
||||
</h3>
|
||||
{isEdit && (
|
||||
<p className="text-xs text-slate-400 mt-0.5 font-mono">
|
||||
Role:{" "}
|
||||
<span className="font-semibold text-amber-400">
|
||||
{user.role.replace("_", " ")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting || isSubmitting}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm hover:bg-red-900/50 transition-colors disabled:opacity-50"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Delete
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isDeleting || isSubmitting}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border border-slate-600 text-slate-300 text-sm hover:bg-slate-800 transition-colors disabled:opacity-50"
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="p-5 space-y-4 overflow-y-auto flex-1 custom-scrollbar"
|
||||
>
|
||||
Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isDeleting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{isEdit ? "Update & Reset" : "Create Account"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{isEdit && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 text-xs text-amber-200/80 mb-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<p>
|
||||
<strong>Password Recovery:</strong> For security reasons, original passwords cannot
|
||||
be viewed. To recover an account, enter a new password below to reset it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfilePictureInput previewPic={previewPic} onChange={handlePicChange} />
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={(e) => setAccountName(e.target.value)}
|
||||
placeholder="Full Name"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Shield className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Role
|
||||
</label>
|
||||
<RoleSelect adminRole={adminRole} value={role} onChange={setRole} modalContext={modalContext} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company Name: hanya untuk User Accounts context */}
|
||||
{modalContext === 'user-accounts' && adminRole === "SUPER_ADMIN" && (role === "COMPANY_ADMIN" || isEdit) && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
🏢 Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Nama Perusahaan (e.g. IFG)"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Checklist: hanya untuk User Accounts context (COMPANY_*) */}
|
||||
{modalContext === 'user-accounts' && (
|
||||
role === "COMPANY_ADMIN" ||
|
||||
role === "COMPANY_OPERATOR" ||
|
||||
role === "COMPANY_VIEWER"
|
||||
) && (
|
||||
<AgentChecklist
|
||||
availableAgents={availableAgents}
|
||||
selectedAgents={selectedAgents}
|
||||
onAgentCheck={handleAgentCheck}
|
||||
agentNamesMap={agentNamesMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Login Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username for login"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
{isEdit ? "Reset Password" : "Password"}{" "}
|
||||
{isEdit && (
|
||||
<span className="text-slate-500">(leave blank to keep unchanged)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "Type new password to override" : "Create password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-900/30 border border-green-800 text-green-300 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2 shrink-0">
|
||||
{isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting || isSubmitting}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm hover:bg-red-900/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isDeleting || isSubmitting}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border border-slate-600 text-slate-300 text-sm hover:bg-slate-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isDeleting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{isEdit ? "Update & Reset" : "Create Account"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
interface ProfilePictureInputProps {
|
||||
previewPic: string | null;
|
||||
onChange: (file: File) => void;
|
||||
}
|
||||
|
||||
export default function ProfilePictureInput({
|
||||
previewPic,
|
||||
onChange,
|
||||
}: ProfilePictureInputProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onChange(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full border-2 border-dashed border-slate-600 flex items-center justify-center cursor-pointer hover:border-amber-500 transition-colors overflow-hidden bg-slate-800"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{previewPic ? (
|
||||
<img src={previewPic} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">Profile Picture</p>
|
||||
<p className="text-xs text-slate-500">Click to upload image</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ChevronDown, Shield } from "lucide-react";
|
||||
|
||||
interface RoleOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface RoleSelectProps {
|
||||
adminRole: string;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
modalContext?: 'agents' | 'user-accounts';
|
||||
}
|
||||
|
||||
// Role untuk konteks Agents (teknikal/operasional)
|
||||
const AGENTS_ROLES: RoleOption[] = [
|
||||
{ value: "EXECUTIVE", label: "Executive" },
|
||||
{ value: "TENANT_ADMIN", label: "Tenant Admin" },
|
||||
{ value: "SOC_ANALYST", label: "SOC Analyst" },
|
||||
{ value: "ENGINEER", label: "Engineer" },
|
||||
];
|
||||
|
||||
// Role untuk konteks User Accounts (customer/client)
|
||||
const USER_ACCOUNTS_ROLES_SUPER: RoleOption[] = [
|
||||
{ value: "COMPANY_ADMIN", label: "Company Admin" },
|
||||
{ value: "COMPANY_OPERATOR", label: "Company Operator" },
|
||||
{ value: "COMPANY_VIEWER", label: "Company Viewer" },
|
||||
];
|
||||
|
||||
const USER_ACCOUNTS_ROLES_COMPANY: RoleOption[] = [
|
||||
{ value: "COMPANY_OPERATOR", label: "Company Operator" },
|
||||
{ value: "COMPANY_VIEWER", label: "Company Viewer" },
|
||||
];
|
||||
|
||||
export default function RoleSelect({ adminRole, value, onChange, modalContext = 'user-accounts' }: RoleSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Pilih role options berdasarkan konteks modal
|
||||
const options: RoleOption[] = (() => {
|
||||
if (modalContext === 'agents') {
|
||||
// Agents page: selalu tampilkan role teknikal saja
|
||||
return AGENTS_ROLES;
|
||||
}
|
||||
// User Accounts page: tampilkan COMPANY_* sesuai admin role
|
||||
if (adminRole === 'SUPER_ADMIN' || adminRole === 'EXECUTIVE') return USER_ACCOUNTS_ROLES_SUPER;
|
||||
return USER_ACCOUNTS_ROLES_COMPANY;
|
||||
})();
|
||||
|
||||
const currentOption = options.find((opt) => opt.value === value) || options[0];
|
||||
|
||||
// Set default role if current value is not in the options
|
||||
useEffect(() => {
|
||||
if (options.length > 0 && !options.some((opt) => opt.value === value)) {
|
||||
onChange(options[0].value);
|
||||
}
|
||||
}, [adminRole, value, onChange, options]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:border-amber-500 hover:border-slate-500 transition-colors text-left"
|
||||
>
|
||||
<span className="truncate">{currentOption?.label || value}</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-slate-400 transition-transform duration-300 ${
|
||||
isOpen ? "rotate-180 text-amber-500" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 max-h-60 overflow-y-auto bg-slate-900 border border-slate-700 rounded-lg shadow-xl z-50 py-1 custom-scrollbar">
|
||||
{options.map((opt) => {
|
||||
const isSelected = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-sm text-left transition-colors truncate flex items-center justify-between ${
|
||||
isSelected
|
||||
? "bg-amber-600/20 text-amber-400 font-medium"
|
||||
: "text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{isSelected && <Shield className="w-3.5 h-3.5 text-amber-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,5 +54,5 @@ export default function ViewAsAgentBanner() {
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { updateAdminAgentUser, deleteAdminAgentUser, createAdminExternalUser, getViewAsHeaders } from "@/lib/admin-api";
|
||||
|
||||
interface UseExternalAccountFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
user?: ManagedUser | null;
|
||||
targetCompany?: string | null;
|
||||
modalContext?: 'agents' | 'user-accounts';
|
||||
}
|
||||
|
||||
export function useExternalAccountForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
user,
|
||||
targetCompany,
|
||||
modalContext = 'user-accounts',
|
||||
}: UseExternalAccountFormProps) {
|
||||
const [adminRole, setAdminRole] = useState<string>("");
|
||||
const [adminCompany, setAdminCompany] = useState<string>("");
|
||||
const [adminAgentUuids, setAdminAgentUuids] = useState<string[]>([]);
|
||||
const [globalAgents, setGlobalAgents] = useState<string[]>([]);
|
||||
const [agentNamesMap, setAgentNamesMap] = useState<Record<string, string>>({});
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [accountName, setAccountName] = useState("");
|
||||
const [role, setRole] = useState("COMPANY_ADMIN");
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
const [previewPic, setPreviewPic] = useState<string | null>(null);
|
||||
const [picFile, setPicFile] = useState<File | null>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Get current admin user context & global agent registry
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
fetch("/api/auth/me", { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
setAdminRole(data.user.role || "");
|
||||
setAdminCompany(data.user.company_name || "");
|
||||
setAdminAgentUuids(data.user.agent_uuids || []);
|
||||
|
||||
// Default role berdasarkan konteks modal
|
||||
if (modalContext === 'agents') {
|
||||
// Agents: default ke TENANT_ADMIN (role teknikal)
|
||||
setRole("TENANT_ADMIN");
|
||||
} else if (data.user.role === "COMPANY_ADMIN") {
|
||||
setRole("COMPANY_OPERATOR");
|
||||
} else {
|
||||
setRole("COMPANY_ADMIN");
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetch("/api/dashboard/agents", { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
if (json.ok && json.agents) {
|
||||
setGlobalAgents(json.agents);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetch("/api/auth/admin/users", { credentials: "include", headers: getViewAsHeaders() })
|
||||
.then(res => {
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.ok && Array.isArray(data.data)) {
|
||||
const map: Record<string, string> = {};
|
||||
data.data.forEach((u: any) => {
|
||||
if (u.role === "AGENT_VIEWER" && u.agent_uuid) {
|
||||
map[u.agent_uuid] = u.account_name || u.agent_uuid;
|
||||
}
|
||||
});
|
||||
setAgentNamesMap(map);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [isOpen]);
|
||||
|
||||
// Load existing user data when in edit mode
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setPicFile(null);
|
||||
if (user) {
|
||||
setUsername(user.username);
|
||||
setAccountName(user.account_name || "");
|
||||
setRole(user.role);
|
||||
setCompanyName(user.company_name || "");
|
||||
setSelectedAgents(user.agent_uuids || []);
|
||||
setPassword("");
|
||||
setPreviewPic(
|
||||
user.profile_picture
|
||||
? `/api/auth/uploads/${user.profile_picture}`
|
||||
: null
|
||||
);
|
||||
} else {
|
||||
setUsername("");
|
||||
setAccountName("");
|
||||
setCompanyName(targetCompany || "");
|
||||
setSelectedAgents([]);
|
||||
setPassword("");
|
||||
setPreviewPic(null);
|
||||
}
|
||||
}, [isOpen, user, targetCompany]);
|
||||
|
||||
const handlePicChange = (file: File) => {
|
||||
setPicFile(file);
|
||||
setPreviewPic(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const handleAgentCheck = (agentUuid: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedAgents(prev => [...prev, agentUuid]);
|
||||
} else {
|
||||
setSelectedAgents(prev => prev.filter(uuid => uuid !== agentUuid));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isEdit = !!user;
|
||||
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (username.trim()) fd.append("username", username.trim());
|
||||
if (password) fd.append("password", password);
|
||||
fd.append("account_name", accountName.trim());
|
||||
if (picFile) fd.append("profile_picture", picFile);
|
||||
|
||||
const finalCompany = adminRole === "COMPANY_ADMIN" ? adminCompany : companyName;
|
||||
if (finalCompany) fd.append("company_name", finalCompany);
|
||||
fd.append("agent_uuids", JSON.stringify(selectedAgents));
|
||||
|
||||
if (isEdit) {
|
||||
fd.append("user_id", String(user.id));
|
||||
await updateAdminAgentUser(fd);
|
||||
setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!");
|
||||
} else {
|
||||
fd.append("role", role);
|
||||
await createAdminExternalUser(fd);
|
||||
setSuccess("External account successfully created!");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!user) return;
|
||||
if (!confirm(`Delete account "${user.username}"? This action cannot be undone.`)) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAdminAgentUser(user.id);
|
||||
setSuccess("Account successfully deleted!");
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const availableAgents = adminRole === "COMPANY_ADMIN" ? adminAgentUuids : globalAgents;
|
||||
|
||||
return {
|
||||
adminRole,
|
||||
adminCompany,
|
||||
username,
|
||||
setUsername,
|
||||
password,
|
||||
setPassword,
|
||||
accountName,
|
||||
setAccountName,
|
||||
role,
|
||||
setRole,
|
||||
companyName,
|
||||
setCompanyName,
|
||||
selectedAgents,
|
||||
previewPic,
|
||||
setPreviewPic,
|
||||
isSubmitting,
|
||||
isDeleting,
|
||||
success,
|
||||
error,
|
||||
setError,
|
||||
availableAgents,
|
||||
agentNamesMap,
|
||||
handlePicChange,
|
||||
handleAgentCheck,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { MapPin, AlertTriangle, ChevronDown, ChevronUp, Plus, Info, Loader2 } from "lucide-react";
|
||||
import * as L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { useTheme } from "next-themes";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import {
|
||||
@@ -12,7 +10,7 @@ import {
|
||||
MAP_CSS_INJECT,
|
||||
type FlowDetail
|
||||
} from "./IndonesiaAgentMapHelpers";
|
||||
import { Agent } from "@/lib/actions/agents";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
|
||||
interface LocationData {
|
||||
agent_uuid: string;
|
||||
@@ -109,185 +107,254 @@ export function IndonesiaAgentMap({
|
||||
// Initialize and Update Map
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
let mounted = true;
|
||||
|
||||
// Build unique mapping of agent UUIDs → their registered coordinates.
|
||||
// Only agents with real entries in CustomAgentLocation are rendered on the map.
|
||||
const agentMap: Record<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {};
|
||||
const buildMarkerHtml = (online: boolean): string => {
|
||||
return `
|
||||
<div class="custom-agent-marker ${online ? 'online' : 'offline'}">
|
||||
<div class="marker-beacon-ring"></div>
|
||||
<div class="marker-glass-disc">
|
||||
<div class="marker-core-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
agents.forEach((agent) => {
|
||||
const uuid = agent.uuid || agent.serial || "";
|
||||
const loc = locations.find((l) => l.agent_uuid === uuid);
|
||||
const boot = async () => {
|
||||
if (!mounted) return;
|
||||
const L = (await import("leaflet")).default;
|
||||
if (!mounted || !mapContainerRef.current) return;
|
||||
|
||||
// Only map agents that have a real coordinate registered.
|
||||
// Agents without coordinates are intentionally excluded from the map.
|
||||
if (loc) {
|
||||
agentMap[uuid] = {
|
||||
lat: loc.latitude,
|
||||
lng: loc.longitude,
|
||||
label: loc.label || "",
|
||||
agentLabel: agent.label || "",
|
||||
status: agent.activated ? "Online" : "Offline",
|
||||
};
|
||||
// Inject CSS once
|
||||
if (!document.getElementById("lf-css-dpi")) {
|
||||
const lnk = document.createElement("link");
|
||||
lnk.id = "lf-css-dpi"; lnk.rel = "stylesheet";
|
||||
lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(lnk);
|
||||
}
|
||||
});
|
||||
|
||||
const activeCoords = Object.values(agentMap);
|
||||
const centerLat = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lat, 0) / activeCoords.length : -6.2088;
|
||||
const centerLng = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lng, 0) / activeCoords.length : 106.8456;
|
||||
|
||||
// Voyager for light mode: colorful, ocean blue, matches Globe Map aesthetic
|
||||
const tileUrl = isDark
|
||||
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
|
||||
|
||||
if (!mapInstanceRef.current) {
|
||||
// Canvas renderer: redraws from geo-coords every frame, no SVG offset bugs
|
||||
const canvasRenderer = L.canvas({ padding: 0.5 });
|
||||
canvasRendererRef.current = canvasRenderer;
|
||||
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
renderer: canvasRenderer,
|
||||
}).setView([centerLat, centerLng], 10);
|
||||
|
||||
const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map);
|
||||
tileLayerRef.current = tiles;
|
||||
|
||||
L.control.zoom({ position: "bottomright" }).addTo(map);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
layerGroupRef.current = L.layerGroup().addTo(map);
|
||||
} else {
|
||||
const map = mapInstanceRef.current;
|
||||
if (tileLayerRef.current) {
|
||||
tileLayerRef.current.setUrl(tileUrl);
|
||||
if (!document.getElementById("dpi-map-extra-css")) {
|
||||
const s = document.createElement("style");
|
||||
s.id = "dpi-map-extra-css";
|
||||
s.textContent = `
|
||||
@keyframes dpi-beacon { 0%,100%{opacity:.18;transform:scale(1);transform-origin:20px 20px;} 50%{opacity:.05;transform:scale(1.65);transform-origin:20px 20px;} }
|
||||
.dpi-beacon { animation: dpi-beacon 2.2s ease-in-out infinite; }
|
||||
@keyframes flowDash { to { stroke-dashoffset:-24; } }
|
||||
.animated-flow-line { animation: flowDash 1.2s linear infinite; }
|
||||
.agent-label-tooltip { background:transparent!important;border:none!important;box-shadow:none!important;padding:0!important; }
|
||||
.agent-label-tooltip::before { display:none!important; }
|
||||
.dpi-agent-label { display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;border:1px solid;white-space:nowrap;letter-spacing:0.02em; }
|
||||
.leaflet-control-attribution { display:none!important; }
|
||||
.leaflet-custom-popup .leaflet-popup-content-wrapper { background:#0a1224!important; border:1px solid rgba(99,102,241,0.25)!important; box-shadow:0 12px 36px rgba(0,0,0,0.65)!important; padding:0!important; border-radius:12px!important; }
|
||||
.leaflet-custom-popup .leaflet-popup-content { margin:0!important;padding:0!important; }
|
||||
|
||||
/* Premium Map Marker Styles */
|
||||
.custom-agent-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.marker-beacon-ring {
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.online .marker-beacon-ring {
|
||||
border: 2px solid #10b981;
|
||||
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
.offline .marker-beacon-ring {
|
||||
border: 2px solid #ef4444;
|
||||
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
@keyframes beacon-ping {
|
||||
0% { transform: scale(0.4); opacity: 0.85; }
|
||||
50% { opacity: 0.45; }
|
||||
100% { transform: scale(1.8); opacity: 0; }
|
||||
}
|
||||
.marker-glass-disc {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: rgba(10, 18, 36, 0.85);
|
||||
border: 2px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.15);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.online .marker-glass-disc {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.offline .marker-glass-disc {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.marker-core-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.online .marker-core-dot {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px #10b981;
|
||||
}
|
||||
.offline .marker-core-dot {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 6px #ef4444;
|
||||
}
|
||||
.custom-agent-marker:hover .marker-glass-disc {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
if (activeCoords.length > 0) {
|
||||
map.panTo([centerLat, centerLng]);
|
||||
}
|
||||
}
|
||||
|
||||
const group = layerGroupRef.current;
|
||||
group.clearLayers();
|
||||
|
||||
const renderer = canvasRendererRef.current;
|
||||
const registeredUuids = new Set(locations.map(l => l.agent_uuid));
|
||||
|
||||
// ─── A. DRAW FLOW LINES ────────────────────────────────────────────────────
|
||||
// Drawn BEFORE markers so markers render on top
|
||||
flows.forEach((flow) => {
|
||||
const src = agentMap[flow.source];
|
||||
const dst = agentMap[flow.target];
|
||||
if (!src || !dst || !registeredUuids.has(flow.source) || !registeredUuids.has(flow.target)) return;
|
||||
|
||||
const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.5), 8);
|
||||
const color = isDark ? "#38bdf8" : "#1d4ed8";
|
||||
|
||||
// Glow / shadow underlay
|
||||
L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color: isDark ? "#0ea5e9" : "#3b82f6",
|
||||
weight: weight + 4,
|
||||
opacity: 0.15,
|
||||
renderer,
|
||||
}).addTo(group);
|
||||
|
||||
// Solid base line
|
||||
const baseLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color,
|
||||
weight,
|
||||
opacity: 0.5,
|
||||
renderer,
|
||||
}).addTo(group);
|
||||
|
||||
// Animated dashed overlay (motion effect via CSS on SVG; canvas uses stroke-dasharray)
|
||||
const animLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color,
|
||||
weight: weight * 0.55,
|
||||
opacity: 0.95,
|
||||
dashArray: "10 15",
|
||||
className: "animated-flow-line",
|
||||
renderer,
|
||||
}).addTo(group);
|
||||
|
||||
const tooltipHtml = getFlowTooltipContent(
|
||||
src.agentLabel,
|
||||
dst.agentLabel,
|
||||
flow.bytes,
|
||||
flow.flowsCount,
|
||||
flow.details || []
|
||||
);
|
||||
|
||||
[baseLine, animLine].forEach(line => {
|
||||
line.bindTooltip(tooltipHtml, {
|
||||
sticky: true,
|
||||
className: "leaflet-custom-popup shadow-2xl",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── B. DRAW AGENT MARKERS (circleMarker = same Canvas, pixel-perfect) ─────
|
||||
Object.entries(agentMap).forEach(([uuid, info]) => {
|
||||
const isOnline = info.status === "Online";
|
||||
const fillColor = isOnline ? "#22c55e" : "#ef4444";
|
||||
const glowColor = isOnline ? "#4ade80" : "#f87171";
|
||||
|
||||
// Outer glow ring
|
||||
L.circleMarker([info.lat, info.lng], {
|
||||
radius: 11,
|
||||
color: glowColor,
|
||||
weight: 2,
|
||||
fillColor: glowColor,
|
||||
fillOpacity: 0.15,
|
||||
opacity: 0.5,
|
||||
renderer,
|
||||
}).addTo(group);
|
||||
|
||||
// Inner solid dot
|
||||
const dot = L.circleMarker([info.lat, info.lng], {
|
||||
radius: 6,
|
||||
color: "#ffffff",
|
||||
weight: 2,
|
||||
fillColor,
|
||||
fillOpacity: 1,
|
||||
opacity: 1,
|
||||
renderer,
|
||||
}).addTo(group);
|
||||
|
||||
// Agent label — permanent tooltip anchored below the dot
|
||||
const labelClass = isDark
|
||||
? "bg-slate-950/90 text-slate-200 border-white/10"
|
||||
: "bg-white/95 text-slate-800 border-slate-200";
|
||||
|
||||
dot.bindTooltip(
|
||||
`<div class="agent-map-label ${labelClass}">${info.agentLabel}</div>`,
|
||||
{ permanent: true, direction: "bottom", offset: [0, 8], className: "agent-label-tooltip" }
|
||||
).openTooltip();
|
||||
|
||||
// Detail popup on click
|
||||
const popupHtml = getAgentTooltipContent(
|
||||
uuid,
|
||||
info.agentLabel,
|
||||
info.status,
|
||||
isOnline,
|
||||
info.label,
|
||||
info.lat,
|
||||
info.lng
|
||||
);
|
||||
|
||||
dot.bindPopup(popupHtml, {
|
||||
closeButton: false,
|
||||
className: "leaflet-custom-popup",
|
||||
offset: [0, -8],
|
||||
});
|
||||
|
||||
dot.on("popupopen", () => {
|
||||
const btn = document.getElementById(`btn-edit-${uuid}`);
|
||||
if (btn) {
|
||||
btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel));
|
||||
// Build agent map
|
||||
const agentMap: Record<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {};
|
||||
agents.forEach((agent) => {
|
||||
const uuid = agent.uuid || agent.serial || "";
|
||||
const loc = locations.find((l) => l.agent_uuid === uuid);
|
||||
if (loc) {
|
||||
agentMap[uuid] = {
|
||||
lat: loc.latitude,
|
||||
lng: loc.longitude,
|
||||
label: loc.label || "",
|
||||
agentLabel: agent.label || "",
|
||||
status: agent.activated ? "Online" : "Offline",
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const activeCoords = Object.values(agentMap);
|
||||
const centerLat = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lat, 0) / activeCoords.length : -6.2088;
|
||||
const centerLng = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lng, 0) / activeCoords.length : 106.8456;
|
||||
|
||||
const tileUrl = isDark
|
||||
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
|
||||
|
||||
if (!mapInstanceRef.current) {
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
zoomAnimation: false, // prevents _leaflet_pos crash on React StrictMode remount
|
||||
fadeAnimation: false,
|
||||
markerZoomAnimation: false,
|
||||
}).setView([centerLat, centerLng], 10, { animate: false });
|
||||
|
||||
const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map);
|
||||
tileLayerRef.current = tiles;
|
||||
L.control.zoom({ position: "bottomright" }).addTo(map);
|
||||
mapInstanceRef.current = map;
|
||||
layerGroupRef.current = L.layerGroup().addTo(map);
|
||||
} else {
|
||||
const map = mapInstanceRef.current;
|
||||
if (tileLayerRef.current) tileLayerRef.current.setUrl(tileUrl);
|
||||
if (activeCoords.length > 0) map.panTo([centerLat, centerLng]);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
const group = layerGroupRef.current;
|
||||
group.clearLayers();
|
||||
const registeredUuids = new Set(locations.map(l => l.agent_uuid));
|
||||
|
||||
// ── A. FLOW LINES ────────────────────────────────────────────────────────
|
||||
flows.forEach((flow) => {
|
||||
const src = agentMap[flow.source];
|
||||
const dst = agentMap[flow.target];
|
||||
if (!src || !dst || !registeredUuids.has(flow.source) || !registeredUuids.has(flow.target)) return;
|
||||
const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.5), 8);
|
||||
const color = isDark ? "#38bdf8" : "#1d4ed8";
|
||||
|
||||
L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color: isDark ? "#0ea5e9" : "#3b82f6", weight: weight + 4, opacity: 0.12,
|
||||
}).addTo(group);
|
||||
|
||||
const baseLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color, weight, opacity: 0.5,
|
||||
}).addTo(group);
|
||||
|
||||
const animLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color, weight: weight * 0.55, opacity: 0.95, dashArray: "10 15",
|
||||
className: "animated-flow-line",
|
||||
}).addTo(group);
|
||||
|
||||
const tooltipHtml = getFlowTooltipContent(
|
||||
src.agentLabel, dst.agentLabel, flow.bytes, flow.flowsCount, flow.details || []
|
||||
);
|
||||
[baseLine, animLine].forEach(line => {
|
||||
line.bindTooltip(tooltipHtml, { sticky: true, className: "leaflet-custom-popup shadow-2xl" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── B. PREMIUM SVG MARKERS ───────────────────────────────────────────────
|
||||
Object.entries(agentMap).forEach(([uuid, info]) => {
|
||||
if (!mounted) return;
|
||||
const isOnline = info.status === "Online";
|
||||
const labelColor = isOnline ? "#34d399" : "#f87171";
|
||||
const borderCol = isOnline ? "#10b981" : "#ef4444";
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: buildMarkerHtml(isOnline),
|
||||
className: "",
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
tooltipAnchor: [0, -18],
|
||||
popupAnchor: [0, -18],
|
||||
});
|
||||
|
||||
const marker = L.marker([info.lat, info.lng], { icon });
|
||||
|
||||
const shortLabel = info.agentLabel.length > 24 ? info.agentLabel.slice(0, 22) + "…" : info.agentLabel;
|
||||
marker.bindTooltip(
|
||||
`<div class="dpi-agent-label" style="background:rgba(10,18,36,0.92);color:${labelColor};border-color:${borderCol};box-shadow:0 0 10px ${borderCol}44,0 2px 8px rgba(0,0,0,0.7);">` +
|
||||
`<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${borderCol};margin-right:5px;${isOnline?`box-shadow:0 0 6px ${borderCol}`:""}"></span>` +
|
||||
`${shortLabel}</div>`,
|
||||
{ permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false }
|
||||
);
|
||||
|
||||
const popupHtml = getAgentTooltipContent(
|
||||
uuid, info.agentLabel, info.status, isOnline, info.label, info.lat, info.lng
|
||||
);
|
||||
marker.bindPopup(popupHtml, { closeButton: false, className: "leaflet-custom-popup", offset: [0, -8] });
|
||||
marker.on("popupopen", () => {
|
||||
const btn = document.getElementById(`btn-edit-${uuid}`);
|
||||
if (btn) btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel));
|
||||
});
|
||||
|
||||
marker.addTo(group);
|
||||
});
|
||||
|
||||
// Fit bounds
|
||||
if (activeCoords.length > 1 && mounted) {
|
||||
const L2 = L as any;
|
||||
const bounds = L2.latLngBounds(activeCoords.map((c: any) => [c.lat, c.lng]));
|
||||
mapInstanceRef.current.fitBounds(bounds, { padding: [60, 60], maxZoom: 14, animate: false });
|
||||
}
|
||||
};
|
||||
|
||||
boot();
|
||||
return () => {
|
||||
mounted = false;
|
||||
// Properly destroy the Leaflet instance so React StrictMode double-invoke
|
||||
// and HMR remounts don't leave orphaned panes that trigger _leaflet_pos errors.
|
||||
if (mapInstanceRef.current) {
|
||||
try { mapInstanceRef.current.remove(); } catch (_) {}
|
||||
mapInstanceRef.current = null;
|
||||
layerGroupRef.current = null;
|
||||
tileLayerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [agents, locations, flows, isDark]);
|
||||
|
||||
if (mapError) {
|
||||
|
||||
@@ -28,17 +28,17 @@ export function KPICards({ summary }: KPICardsProps) {
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-blue-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '0ms' }}>
|
||||
<Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-blue-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '0ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Download className="h-16 w-16 text-blue-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-blue-400 transition-colors">Download</CardTitle>
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary ? fmtBytes(summary.bandwidth_down) : "0 B"}
|
||||
</div>
|
||||
{summary && (
|
||||
@@ -55,17 +55,17 @@ export function KPICards({ summary }: KPICardsProps) {
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-emerald-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '50ms' }}>
|
||||
<Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-emerald-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '50ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Upload className="h-16 w-16 text-emerald-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-emerald-400 transition-colors">Upload</CardTitle>
|
||||
<Upload className="h-4 w-4 text-emerald-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary ? fmtBytes(summary.bandwidth_up) : "0 B"}
|
||||
</div>
|
||||
{summary && (
|
||||
@@ -82,77 +82,82 @@ export function KPICards({ summary }: KPICardsProps) {
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/devices" className="block focus:outline-none focus:ring-2 focus:ring-orange-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
<Link href="/devices" className="block h-full focus:outline-none focus:ring-2 focus:ring-orange-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Server className="h-16 w-16 text-orange-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-orange-400 transition-colors">Devices</CardTitle>
|
||||
<Server className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary?.total_devices?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/flows" className="block focus:outline-none focus:ring-2 focus:ring-purple-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '150ms' }}>
|
||||
<Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-purple-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '150ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Activity className="h-16 w-16 text-purple-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-purple-400 transition-colors">Flows</CardTitle>
|
||||
<Activity className="h-4 w-4 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary?.active_flows?.toLocaleString() || "0"}
|
||||
</div>
|
||||
{summary && flowsPerHour > 0 && (
|
||||
{summary && flowsPerHour > 0 ? (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-purple-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse" />
|
||||
Speed: {flowsPerHour.toLocaleString()} /hr
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/threats" className="block focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-red-50/90 dark:bg-card/80 backdrop-blur-xl border-red-200 dark:border-red-500/20 hover:bg-red-100 hover:dark:bg-card/100 hover:border-red-300 hover:dark:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<Link href="/threats" className="block h-full focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-red-50/90 dark:bg-card/80 backdrop-blur-xl border-red-200 dark:border-red-500/20 hover:bg-red-100 hover:dark:bg-card/100 hover:border-red-300 hover:dark:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className="absolute inset-0 bg-red-500/10 dark:bg-red-500/5 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 p-4 opacity-20 dark:opacity-10 group-hover:opacity-30 dark:group-hover:opacity-20 transition-opacity text-red-600 dark:text-red-500">
|
||||
<ShieldAlert className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-semibold text-red-700 dark:text-red-400 group-hover:text-red-800 dark:group-hover:text-red-300 transition-colors">Threats</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-500 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-red-600 dark:text-red-500 tracking-tight">
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-red-600 dark:text-red-500 tracking-tight whitespace-nowrap">
|
||||
{summary?.total_threats?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/events" className="block focus:outline-none focus:ring-2 focus:ring-yellow-500/50 rounded-xl">
|
||||
<Card className="group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '250ms' }}>
|
||||
<Link href="/events" className="block h-full focus:outline-none focus:ring-2 focus:ring-yellow-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '250ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-yellow-500">
|
||||
<Zap className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 relative z-10">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-yellow-400 transition-colors">Events</CardTitle>
|
||||
<Zap className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="relative z-10">
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary?.total_events?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -41,7 +41,10 @@ export function TopWidgets({ topApps, topProtocols }: TopWidgetsProps) {
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => fmtBytes(value)}
|
||||
tickFormatter={(value) => {
|
||||
const mb = value / 1048576;
|
||||
return `${mb.toFixed(0)}M`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ContextualHelpModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SectionBlock({ heading, content, columns, items }: PageGuide["sections"][0]) {
|
||||
function SectionBlock({ heading, content, steps, columns, items }: PageGuide["sections"][0]) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
@@ -37,11 +37,24 @@ function SectionBlock({ heading, content, columns, items }: PageGuide["sections"
|
||||
<p className="text-xs text-slate-400 leading-relaxed">{content}</p>
|
||||
)}
|
||||
|
||||
{steps && steps.length > 0 && (
|
||||
<ol className="space-y-2">
|
||||
{steps.map((step, i) => (
|
||||
<li key={i} className="flex gap-2.5 items-start">
|
||||
<span className="shrink-0 flex h-4.5 w-4.5 min-w-[18px] items-center justify-center rounded-full bg-blue-600/30 border border-blue-500/40 text-[9px] font-bold text-blue-300 mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-xs text-slate-300 leading-relaxed">{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{columns && columns.length > 0 && (
|
||||
<div className="rounded-lg overflow-hidden border border-white/5">
|
||||
<div className="grid grid-cols-[140px_1fr] bg-white/[0.04] px-3 py-2 border-b border-white/5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-blue-400">Kolom</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Cara Membaca</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-blue-400">Column</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">How to Read</span>
|
||||
</div>
|
||||
{columns.map((col, i) => (
|
||||
<div
|
||||
@@ -136,12 +149,9 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-bold text-white">Panduan: {guide.title}</h3>
|
||||
<code className="text-[10px] px-1.5 py-0.5 rounded font-mono bg-white/5 text-slate-400 border border-white/5">
|
||||
{guide.url}
|
||||
</code>
|
||||
<h3 className="text-base font-bold text-white">Guide: {guide.title}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Penjelasan tata letak data dan kolom metrik</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Metric columns and data layout guide</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -155,7 +165,7 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM
|
||||
{/* Scrollable Body */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
|
||||
<div className="rounded-xl border border-blue-500/10 bg-blue-500/5 px-4 py-3 text-xs text-slate-300 leading-relaxed">
|
||||
<span className="font-semibold text-blue-400">Tentang Halaman Ini:</span> {guide.summary}
|
||||
<span className="font-semibold text-blue-400">About This Page:</span> {guide.summary}
|
||||
</div>
|
||||
|
||||
{guide.sections.map((section, idx) => (
|
||||
@@ -166,20 +176,20 @@ export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpM
|
||||
{/* Footer */}
|
||||
<div className="border-t border-white/6 bg-white/[0.02] px-6 py-4 flex items-center justify-between">
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Pencarian detail lengkap tersedia di User Guide utama.
|
||||
Full detailed lookup is available in the main User Guide.
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-white/8 bg-white/5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white transition-all"
|
||||
>
|
||||
Tutup Panduan
|
||||
Close Guide
|
||||
</button>
|
||||
<Link
|
||||
href="/help"
|
||||
className="flex items-center gap-1 rounded-xl bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-md shadow-blue-600/10 hover:bg-blue-500 transition-all"
|
||||
>
|
||||
Buka Panduan Lengkap
|
||||
Open Full Guide
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ChevronRight, Home, Radio, Shuffle, Package, Monitor,
|
||||
Globe2, BarChart3, Map, Shield, Bell, Lock, BookOpen,
|
||||
ChevronDown, ChevronUp, Info, Lightbulb, ArrowLeft,
|
||||
Activity, Plug, Globe, Search
|
||||
Activity, Plug, Globe, Search, Tag
|
||||
} from "lucide-react";
|
||||
import { PAGE_GUIDES, UI_CONVENTIONS, type PageGuide } from "@/lib/helpContent";
|
||||
|
||||
@@ -26,9 +26,10 @@ const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
"security-audit": <Lock className="h-4 w-4" />,
|
||||
"network-infrastructure": <Globe className="h-4 w-4" />,
|
||||
lookup: <Search className="h-4 w-4" />,
|
||||
"device-labeling": <Tag className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
function SectionBlock({ heading, content, columns, items }: PageGuide["sections"][0]) {
|
||||
function SectionBlock({ heading, content, steps, columns, items }: PageGuide["sections"][0]) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/8 bg-gradient-to-b from-slate-900/80 to-slate-900/40 overflow-hidden shadow-lg">
|
||||
@@ -46,11 +47,23 @@ function SectionBlock({ heading, content, columns, items }: PageGuide["sections"
|
||||
{content && (
|
||||
<p className="text-sm text-slate-400 leading-relaxed">{content}</p>
|
||||
)}
|
||||
{steps && (
|
||||
<ol className="space-y-2">
|
||||
{steps.map((step, i) => (
|
||||
<li key={i} className="flex gap-3 items-start">
|
||||
<span className="shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-blue-600/30 border border-blue-500/40 text-[10px] font-bold text-blue-300 mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-sm text-slate-300 leading-relaxed">{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{columns && (
|
||||
<div className="rounded-xl overflow-hidden border border-white/8">
|
||||
<div className="grid grid-cols-[180px_1fr] bg-gradient-to-r from-blue-600/20 to-indigo-600/10 px-4 py-2.5 border-b border-white/8">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-blue-400">Kolom / Field</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Cara Membaca</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-blue-400">Column / Field</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">How to Read</span>
|
||||
</div>
|
||||
{columns.map((col, i) => (
|
||||
<div key={i} className={`grid grid-cols-[180px_1fr] px-4 py-3 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}>
|
||||
@@ -95,12 +108,12 @@ export default function HelpPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-white">User Guide</h1>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Panduan membaca data dashboard Deep Package Inspection</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Guide to reading Deep Packet Inspection dashboard data</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/" className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-xs font-medium text-slate-300 hover:bg-white/10 hover:text-white transition-all hover:border-white/20 group">
|
||||
<ArrowLeft className="h-3.5 w-3.5 group-hover:-translate-x-0.5 transition-transform" />
|
||||
Kembali ke Dashboard
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,7 +122,7 @@ export default function HelpPage() {
|
||||
{/* ── Sidebar ── */}
|
||||
<aside className="w-52 shrink-0">
|
||||
<div className="sticky top-6 space-y-1">
|
||||
<p className="px-3 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-500">Halaman Dashboard</p>
|
||||
<p className="px-3 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-500">Dashboard Pages</p>
|
||||
{PAGE_GUIDES.map(page => {
|
||||
const isActive = activeId === page.id;
|
||||
return (
|
||||
@@ -140,7 +153,7 @@ export default function HelpPage() {
|
||||
>
|
||||
{activeId === "conventions" && <span className="absolute left-0 top-1/4 h-1/2 w-0.5 rounded-full bg-blue-500" />}
|
||||
<Info className={`h-4 w-4 shrink-0 ${activeId === "conventions" ? "text-blue-400" : "text-slate-500"}`} />
|
||||
<span>Konvensi Visual</span>
|
||||
<span>Visual Conventions</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,15 +167,15 @@ export default function HelpPage() {
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/30 to-indigo-500/20 border border-purple-500/20 text-lg">🎨</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Konvensi Visual</h2>
|
||||
<p className="text-xs text-slate-400">Panduan membaca indikator dan simbol di seluruh halaman dashboard</p>
|
||||
<h2 className="text-lg font-bold text-white">Visual Conventions</h2>
|
||||
<p className="text-xs text-slate-400">Guide to reading indicators and symbols across dashboard pages</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 overflow-hidden shadow-lg">
|
||||
<div className="grid grid-cols-[200px_1fr] bg-gradient-to-r from-purple-600/20 to-indigo-600/10 px-5 py-3 border-b border-white/8">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-purple-400">Simbol / Elemen</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Artinya</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-purple-400">Symbol / Element</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Meaning</span>
|
||||
</div>
|
||||
{UI_CONVENTIONS.map((c, i) => (
|
||||
<div key={i} className={`grid grid-cols-[200px_1fr] px-5 py-3.5 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}>
|
||||
@@ -174,10 +187,17 @@ export default function HelpPage() {
|
||||
<div className="rounded-2xl border border-blue-500/20 bg-gradient-to-br from-blue-600/10 to-indigo-600/5 p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lightbulb className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm font-semibold text-yellow-300">Tips Membaca Data Secara Efektif</span>
|
||||
<span className="text-sm font-semibold text-yellow-300">Tips for Reading Data Effectively</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-xs text-slate-300">
|
||||
{["Selalu cek Time Filter sebelum menganalisis — pastikan rentang waktu sesuai kebutuhan.","Gunakan filter tabel untuk mempersempit data secara spesifik.","Hover ke teks yang terpotong (...) untuk melihat nilai lengkap.","Bandingkan Download vs Upload — upload jauh lebih besar bisa mengindikasikan data exfiltration.","Gunakan Geo Traffic untuk mendeteksi koneksi ke negara yang tidak seharusnya.","Cek Threat Intelligence secara rutin untuk mengetahui ancaman aktif."].map((tip, i) => (
|
||||
{[
|
||||
"Always check the Time Filter before analyzing — ensure the time range fits your needs.",
|
||||
"Use table filters to narrow down data specifically.",
|
||||
"Hover over truncated text (...) to view the full value.",
|
||||
"Compare Download vs Upload — a significantly larger upload could indicate data exfiltration.",
|
||||
"Use Geo Traffic to detect connections to countries that should not be accessed.",
|
||||
"Regularly check Threat Intelligence to identify active threats."
|
||||
].map((tip, i) => (
|
||||
<li key={i} className="flex gap-2"><span className="text-blue-400 shrink-0 mt-0.5">•</span><span className="leading-relaxed">{tip}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -207,7 +227,7 @@ export default function HelpPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center text-slate-500 text-sm">
|
||||
Pilih halaman dari sidebar untuk melihat panduannya.
|
||||
Select a page from the sidebar to view its guide.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { ContextualHelpModal } from "./ContextualHelpModal";
|
||||
|
||||
interface HelpTriggerProps {
|
||||
@@ -17,10 +17,10 @@ export function HelpTrigger({ pageId, className = "" }: HelpTriggerProps) {
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`group flex items-center gap-1.5 rounded-lg border border-white/8 bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white hover:border-white/20 transition-all ${className}`}
|
||||
title="Pelajari cara membaca data halaman ini"
|
||||
title="Learn how to read data on this page"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5 text-blue-500 group-hover:text-blue-400 transition-colors" />
|
||||
<span>Pelajari Halaman Ini</span>
|
||||
<BookOpen className="h-3.5 w-3.5 text-blue-500 group-hover:text-blue-400 transition-colors" />
|
||||
<span>Learn This Page</span>
|
||||
</button>
|
||||
|
||||
<ContextualHelpModal
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { X, Info, Box, Tag, Shield, Server, AppWindow } from "lucide-react";
|
||||
import { LookupApp } from "@/lib/api";
|
||||
|
||||
interface AppLookupDetailModalProps {
|
||||
selectedApp: LookupApp;
|
||||
isClosingModal: boolean;
|
||||
handleCloseModal: () => void;
|
||||
}
|
||||
|
||||
export function AppLookupDetailModal({ selectedApp, isClosingModal, handleCloseModal }: AppLookupDetailModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6" onClick={handleCloseModal}>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosingModal ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||
/>
|
||||
<div
|
||||
className={`relative w-full max-w-2xl bg-card border border-border rounded-2xl shadow-2xl shadow-black/60 overflow-hidden ${isClosingModal ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header Background */}
|
||||
<div className="h-32 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent relative">
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="absolute top-4 right-4 p-2 bg-black/20 hover:bg-black/40 rounded-full text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 sm:px-8 sm:pb-8 relative -mt-16">
|
||||
{/* Icon/Logo */}
|
||||
<div className="w-24 h-24 bg-card rounded-2xl border-4 border-card flex items-center justify-center overflow-hidden shadow-xl relative z-10 mb-4 bg-secondary">
|
||||
{selectedApp.logo || selectedApp.favicon ? (
|
||||
<img
|
||||
src={selectedApp.logo || selectedApp.favicon}
|
||||
alt={selectedApp.label}
|
||||
className="w-16 h-16 object-contain"
|
||||
onError={(e) => {
|
||||
const img = e.target as HTMLElement;
|
||||
img.style.display = 'none';
|
||||
const sibling = img.nextSibling as HTMLElement;
|
||||
if (sibling) sibling.style.display = 'block';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<AppWindow className="w-12 h-12 text-slate-400" style={{ display: (selectedApp.logo || selectedApp.favicon) ? 'none' : 'block' }} />
|
||||
</div>
|
||||
|
||||
{/* Title & Category */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white tracking-tight">{selectedApp.label}</h2>
|
||||
{selectedApp.application_category?.label && (
|
||||
<span className="px-3 py-1 bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-widest rounded-full">
|
||||
{selectedApp.application_category.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedApp.full_name && selectedApp.full_name !== selectedApp.label && (
|
||||
<p className="text-slate-400 font-medium">{selectedApp.full_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Description */}
|
||||
<div className="space-y-4 md:col-span-2">
|
||||
<div className="flex items-start gap-3 text-slate-300 bg-secondary/30 p-4 rounded-xl border border-border/30">
|
||||
<Info className="w-5 h-5 text-primary shrink-0 mt-0.5" />
|
||||
<p className="text-sm leading-relaxed">
|
||||
{selectedApp.description || "No detailed description provided for this application or service."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Info */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">System Metadata</h3>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Box className="w-4 h-4" />
|
||||
<span className="text-sm">App ID</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Tag className="w-4 h-4" />
|
||||
<span className="text-sm">System Name</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.name || 'N/A'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="text-sm">Tag / Alias</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.tag || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Application Category Detail */}
|
||||
{selectedApp.application_category && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">Category Info</h3>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Server className="w-4 h-4" />
|
||||
<span className="text-sm">Category ID</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Info className="w-4 h-4" />
|
||||
<span className="text-sm">Category Tag</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "../ui/sharedPdfStyles";
|
||||
|
||||
interface SecurityAuditPdfDocumentProps {
|
||||
secDevices: any[];
|
||||
versionsData: any[];
|
||||
ciphersData: any[];
|
||||
sslSansData: any[];
|
||||
vulnerableCount: number;
|
||||
moderateCount: number;
|
||||
safeCount: number;
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
interface TableColumn {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right" | "left";
|
||||
cell: (row: any) => string | number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1000;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr: string | Date): string {
|
||||
if (!dateStr) return "Never";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch (e) {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 15 }}>
|
||||
<Text style={sharedStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
{columns.map((col, idx) => (
|
||||
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
|
||||
{col.header}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No Data</Text>
|
||||
</View>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
|
||||
{col.cell(row)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SecurityAuditPdfDocument({
|
||||
secDevices,
|
||||
versionsData,
|
||||
ciphersData,
|
||||
sslSansData,
|
||||
vulnerableCount,
|
||||
moderateCount,
|
||||
safeCount,
|
||||
reportDateStr,
|
||||
}: SecurityAuditPdfDocumentProps) {
|
||||
// Compliance check: show all data up to 50 threshold
|
||||
const vulnerableDevices = secDevices.filter((d) => d.risk_level === "Vulnerable").slice(0, 50);
|
||||
const otherDevices = secDevices.filter((d) => d.risk_level !== "Vulnerable").slice(0, 50);
|
||||
const sslSans = (sslSansData || []).slice(0, 50);
|
||||
const versions = (versionsData || []).slice(0, 50);
|
||||
const ciphers = (ciphersData || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Security & Encryption Audit</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Global Security Summary */}
|
||||
<Text style={sharedStyles.sectionTitle}>Compliance & Risk Summary</Text>
|
||||
<View style={sharedStyles.bandwidthGrid}>
|
||||
<View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fef2f2", borderColor: "#fecaca" }}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#b91c1c" }}>VULNERABLE DEVICES</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#991b1b" }}>{vulnerableCount}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fffbeb", borderColor: "#fef3c7" }}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#d97706" }}>MODERATE RISK DEVICES</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#b45309" }}>{moderateCount}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.bandwidthCardGreen}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>SECURE DEVICES</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{safeCount}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* TLS Versions */}
|
||||
<RenderPdfTable
|
||||
title="TLS Version Distribution"
|
||||
data={versions}
|
||||
columns={[
|
||||
{ header: "Protocol Version", width: "40%", cell: (r) => r.version || "Unknown TLS" },
|
||||
{ header: "Flow Count", width: "30%", align: "right", cell: (r) => r.count ?? 0 },
|
||||
{ header: "Data Transmitted", width: "30%", align: "right", cell: (r) => formatBytes(r.bytes) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Top Active Cipher Suites */}
|
||||
<RenderPdfTable
|
||||
title="Top Active Cipher Suites"
|
||||
data={ciphers}
|
||||
columns={[
|
||||
{ header: "Cipher Suite", width: "50%", cell: (r) => r.cipher || "Unknown" },
|
||||
{ header: "Version", width: "20%", cell: (r) => r.version || "—" },
|
||||
{ header: "Usage Count", width: "30%", align: "right", cell: (r) => r.count ?? 0 },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* SSL/TLS Subject Alternative Names */}
|
||||
<RenderPdfTable
|
||||
title="SSL/TLS Subject Alternative Names (SAN)"
|
||||
data={sslSans}
|
||||
columns={[
|
||||
{ header: "Subject Alternative Name (SAN)", width: "40%", cell: (r) => r.alt_name || "—" },
|
||||
{ header: "Flows", width: "15%", align: "center", cell: (r) => r.flow_count ?? 0 },
|
||||
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
{ header: "Last Seen", width: "15%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Vulnerable Devices Table */}
|
||||
<RenderPdfTable
|
||||
title="Vulnerable Devices (Action Required)"
|
||||
data={vulnerableDevices}
|
||||
columns={[
|
||||
{ header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Device Label / OS", width: "30%", cell: (r) => r.device_label || r.os_label || "Unknown" },
|
||||
{ header: "Encrypted %", width: "20%", align: "right", cell: (r) => `${r.encrypted_pct ?? 0}%` },
|
||||
{ header: "Risk Level", width: "20%", align: "center", cell: (r) => r.risk_level || "—" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Other Devices Table */}
|
||||
<RenderPdfTable
|
||||
title="Other Monitored Devices"
|
||||
data={otherDevices}
|
||||
columns={[
|
||||
{ header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Device Label / OS", width: "30%", cell: (r) => r.device_label || r.os_label || "Unknown" },
|
||||
{ header: "Encrypted %", width: "20%", align: "right", cell: (r) => `${r.encrypted_pct ?? 0}%` },
|
||||
{ header: "Risk Level", width: "20%", align: "center", cell: (r) => r.risk_level || "—" },
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -176,4 +176,4 @@ export function TlsCipherDetailModal({ cipher, onClose }: Props) {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -128,4 +128,4 @@ export default function TlsTables({ versionsData, ciphersData }: Props) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,12 @@ export default function TlsVersionsChart({ versionsData }: Props) {
|
||||
scale="log"
|
||||
domain={[1, 'auto']}
|
||||
allowDataOverflow
|
||||
tickFormatter={(value) => fmtBytes(value)}
|
||||
tickFormatter={(value) => {
|
||||
if (value >= 1073741824) return `${(value / 1073741824).toFixed(0)}GB`;
|
||||
if (value >= 1048576) return `${(value / 1048576).toFixed(0)}MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(0)}KB`;
|
||||
return `${value}B`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip content={<TlsVersionTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||||
<Bar dataKey="total" radius={[4, 4, 0, 0]} minPointSize={6}>
|
||||
|
||||
@@ -218,4 +218,4 @@ export function ThreatFilters({
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -200,4 +200,4 @@ export function ThreatRecommendationsDrawer({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { ShieldCheck, Loader2 } from "lucide-react";
|
||||
import { ThreatStat } from "@/lib/api";
|
||||
import { useThreats } from "@/lib/api-with-context";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { ThreatFilters } from "@/components/threats/ThreatFilters";
|
||||
import { ThreatRecommendationsDrawer } from "@/components/threats/ThreatRecommendationsDrawer";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import { getThreatColumns } from "./threatColumns";
|
||||
|
||||
export default function ThreatsContent() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
const [selectedMac, setSelectedMac] = useState<string | null>(null);
|
||||
const [selectedThreat, setSelectedThreat] = useState<ThreatStat | null>(null);
|
||||
|
||||
const threats = useThreats();
|
||||
const searchParams = useSearchParams();
|
||||
const highlightId = searchParams.get("highlight");
|
||||
|
||||
// Column dropdown filters state
|
||||
const [fType, setFType] = useState("All");
|
||||
const [fSev, setFSev] = useState("All");
|
||||
const [fSrcIp, setFSrcIp] = useState("All");
|
||||
const [fDstIp, setFDstIp] = useState("All");
|
||||
const [fMac, setFMac] = useState("All");
|
||||
const [fApp, setFApp] = useState("All");
|
||||
const [fDomain, setFDomain] = useState("All");
|
||||
|
||||
// Date range filter state
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Detected Threats | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
const all = threats.data || [];
|
||||
|
||||
// Apply all filters
|
||||
const filtered = useMemo(() => {
|
||||
return all.filter((row) => {
|
||||
if (fType !== "All" && (row.threat_type || "") !== fType) return false;
|
||||
if (fSev !== "All" && (row.severity || "") !== fSev) return false;
|
||||
if (fSrcIp !== "All" && (row.ip_address || "") !== fSrcIp) return false;
|
||||
if (fDstIp !== "All" && (row.dst_ip || "") !== fDstIp) return false;
|
||||
if (fMac !== "All" && (row.mac_address || "") !== fMac) return false;
|
||||
if (fApp !== "All" && (row.app_label || "") !== fApp) return false;
|
||||
if (fDomain !== "All" && (row.domain || "") !== fDomain) return false;
|
||||
if (dateFrom || dateTo) {
|
||||
const d = row.detected_at
|
||||
? new Date(row.detected_at).toISOString().slice(0, 10)
|
||||
: "";
|
||||
if (dateFrom && d < dateFrom) return false;
|
||||
if (dateTo && d > dateTo) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [all, fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain, dateFrom, dateTo]);
|
||||
|
||||
// Default sort: newest detected threats first (for table display)
|
||||
const sortedFiltered = useMemo(() =>
|
||||
[...filtered].sort((a, b) =>
|
||||
new Date(b.detected_at || 0).getTime() - new Date(a.detected_at || 0).getTime()
|
||||
),
|
||||
[filtered]
|
||||
);
|
||||
|
||||
// Automatically select a threat if highlightId is passed
|
||||
useEffect(() => {
|
||||
if (highlightId && all.length > 0) {
|
||||
const match = all.find(r => r.id.toString() === highlightId);
|
||||
if (match) {
|
||||
setSelectedThreat(match);
|
||||
}
|
||||
}
|
||||
}, [highlightId, all]);
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const exportToPdf = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { pdf } = await import("@react-pdf/renderer");
|
||||
const { default: ThreatsSummaryPdfDocument } = await import("./ThreatsSummaryPdfDocument");
|
||||
|
||||
const reportDateStr =
|
||||
new Date().toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) + " WIB";
|
||||
|
||||
const doc = <ThreatsSummaryPdfDocument threatsData={filtered} reportDateStr={reportDateStr} />;
|
||||
|
||||
const blob = await pdf(doc).toBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `Threat_Intelligence_Summary_Report.pdf`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Failed to export PDF:", err);
|
||||
alert("Failed to generate PDF report. Please try again.");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => getThreatColumns(setSelectedIp, setSelectedMac, setSelectedThreat, highlightId), [highlightId]);
|
||||
|
||||
const activeFilterCount =
|
||||
[fType, fSev, fSrcIp, fDstIp, fMac, fApp, fDomain].filter((v) => v !== "All").length +
|
||||
(dateFrom || dateTo ? 1 : 0);
|
||||
|
||||
const resetFilters = () => {
|
||||
setFType("All"); setFSev("All"); setFSrcIp("All"); setFDstIp("All");
|
||||
setFMac("All"); setFApp("All"); setFDomain("All"); setDateFrom(""); setDateTo("");
|
||||
};
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Detected Threats
|
||||
{threats.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<HelpTrigger pageId="threats" />
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<span>Active Monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThreatFilters
|
||||
all={all}
|
||||
fType={fType}
|
||||
setFType={setFType}
|
||||
fSev={fSev}
|
||||
setFSev={setFSev}
|
||||
fSrcIp={fSrcIp}
|
||||
setFSrcIp={setFSrcIp}
|
||||
fDstIp={fDstIp}
|
||||
setFDstIp={setFDstIp}
|
||||
fMac={fMac}
|
||||
setFMac={setFMac}
|
||||
fApp={fApp}
|
||||
setFApp={setFApp}
|
||||
fDomain={fDomain}
|
||||
setFDomain={setFDomain}
|
||||
dateFrom={dateFrom}
|
||||
setDateFrom={setDateFrom}
|
||||
dateTo={dateTo}
|
||||
setDateTo={setDateTo}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
<Card
|
||||
className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up relative z-10 overflow-visible"
|
||||
style={{ animationDelay: "100ms" }}
|
||||
>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-lg text-white">
|
||||
Detected Threats
|
||||
<span className="ml-2 text-muted-foreground font-normal">
|
||||
({filtered.length}
|
||||
{filtered.length !== all.length ? ` of ${all.length}` : ""})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={sortedFiltered}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search threats by IP, domain, type..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.domain || "").toLowerCase().includes(q) ||
|
||||
(row.threat_type || "").toLowerCase().includes(q) ||
|
||||
(row.app_label || "").toLowerCase().includes(q)
|
||||
);
|
||||
}}
|
||||
getRowClassName={(row) =>
|
||||
highlightId && row.id.toString() === highlightId
|
||||
? "bg-red-500/15 border-l-2 border-red-500 animate-pulse font-semibold"
|
||||
: ""
|
||||
}
|
||||
csvExport={{
|
||||
filename: `threats-${new Date().toISOString().slice(0, 10)}.csv`,
|
||||
headers: ["Timestamp", "Type", "Severity", "Source IP", "Dest IP", "MAC Address", "App", "Domain"],
|
||||
rowSerializer: (row) => [
|
||||
row.detected_at ? new Date(row.detected_at).toISOString() : "",
|
||||
row.threat_type || "",
|
||||
row.severity || "",
|
||||
row.ip_address || "",
|
||||
row.dst_ip || "",
|
||||
row.mac_address || "",
|
||||
row.app_label || "",
|
||||
row.domain || "",
|
||||
],
|
||||
}}
|
||||
pdfExport={{
|
||||
onExport: exportToPdf,
|
||||
isExporting,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedIp && <DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />}
|
||||
{selectedMac && <DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />}
|
||||
|
||||
<ThreatRecommendationsDrawer
|
||||
threat={selectedThreat}
|
||||
onClose={() => setSelectedThreat(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "../ui/sharedPdfStyles";
|
||||
|
||||
interface ThreatsSummaryPdfDocumentProps {
|
||||
threatsData: any[];
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
interface TableColumn {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right" | "left";
|
||||
cell: (row: any) => string | number;
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr: string | Date): string {
|
||||
if (!dateStr) return "Never";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch (e) {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 15 }}>
|
||||
<Text style={sharedStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
{columns.map((col, idx) => (
|
||||
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
|
||||
{col.header}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No Data</Text>
|
||||
</View>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
|
||||
{col.cell(row)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ThreatsSummaryPdfDocument({
|
||||
threatsData,
|
||||
reportDateStr,
|
||||
}: ThreatsSummaryPdfDocumentProps) {
|
||||
const total = threatsData.length;
|
||||
const criticalHigh = threatsData.filter(
|
||||
(t) => {
|
||||
const s = (t.severity || "").toLowerCase();
|
||||
return s.includes("high") || s.includes("critical");
|
||||
}
|
||||
).length;
|
||||
|
||||
const medium = threatsData.filter(
|
||||
(t) => (t.severity || "").toLowerCase().includes("med")
|
||||
).length;
|
||||
|
||||
const lowInfo = total - criticalHigh - medium;
|
||||
|
||||
// Group by threat type
|
||||
const typeCounts: { [key: string]: number } = {};
|
||||
threatsData.forEach((t) => {
|
||||
const key = t.threat_type || "Unknown Alert";
|
||||
typeCounts[key] = (typeCounts[key] || 0) + 1;
|
||||
});
|
||||
const topTypes = Object.entries(typeCounts)
|
||||
.map(([type, count]) => ({ type, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
// Group by source IP
|
||||
const ipCounts: { [key: string]: number } = {};
|
||||
threatsData.forEach((t) => {
|
||||
const key = t.ip_address || "Unknown IP";
|
||||
ipCounts[key] = (ipCounts[key] || 0) + 1;
|
||||
});
|
||||
const topIps = Object.entries(ipCounts)
|
||||
.map(([ip, count]) => ({ ip, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
// Sort incidents by severity (Critical > High > Medium > Low > Info) for PDF export
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
critical: 0, high: 1, medium: 2, med: 2, low: 3, info: 4,
|
||||
};
|
||||
const getSeverityRank = (sev: string): number => {
|
||||
const s = (sev || "").toLowerCase();
|
||||
const match = Object.entries(SEVERITY_RANK).find(([k]) => s.includes(k));
|
||||
return match ? match[1] : 5;
|
||||
};
|
||||
const incidents = [...threatsData]
|
||||
.sort((a, b) => getSeverityRank(a.severity) - getSeverityRank(b.severity))
|
||||
.slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Threat Intelligence Summary</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Threat Counters */}
|
||||
<Text style={sharedStyles.sectionTitle}>Threat Metrics Summary</Text>
|
||||
<View style={sharedStyles.bandwidthGrid}>
|
||||
<View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fef2f2", borderColor: "#fecaca" }}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#b91c1c" }}>CRITICAL / HIGH THREATS</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#991b1b" }}>{criticalHigh}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.bandwidthCardCyan, backgroundColor: "#fffbeb", borderColor: "#fef3c7" }}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#d97706" }}>MEDIUM THREATS</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#b45309" }}>{medium}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.bandwidthCardGreen}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>LOW / INFO THREATS</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{lowInfo}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Threat Types & Targets */}
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 10 }}>
|
||||
<View style={{ width: "48%" }}>
|
||||
<RenderPdfTable
|
||||
title="Top Threat Types"
|
||||
data={topTypes}
|
||||
columns={[
|
||||
{ header: "Threat Type", width: "70%", cell: (r) => r.type },
|
||||
{ header: "Incidents", width: "30%", align: "right", cell: (r) => r.count },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ width: "48%" }}>
|
||||
<RenderPdfTable
|
||||
title="Top Targeted Host IPs"
|
||||
data={topIps}
|
||||
columns={[
|
||||
{ header: "Host IP Address", width: "70%", cell: (r) => r.ip },
|
||||
{ header: "Incidents", width: "30%", align: "right", cell: (r) => r.count },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Detailed Incidents List */}
|
||||
<RenderPdfTable
|
||||
title="Detailed Threat Log (Latest 30)"
|
||||
data={incidents}
|
||||
columns={[
|
||||
{ header: "Time", width: "25%", cell: (r) => r.detected_at ? formatIndonesiaTime(r.detected_at) : "N/A" },
|
||||
{ header: "Threat Type", width: "25%", cell: (r) => r.threat_type || "Security Alert" },
|
||||
{ header: "Severity", width: "15%", align: "center", cell: (r) => (r.severity || "Unknown").toUpperCase() },
|
||||
{ header: "Host IP", width: "15%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Target Destination", width: "20%", cell: (r) => r.dst_ip || r.domain || "—" },
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -158,4 +158,4 @@ export function getMitigationSteps(type: string): { intro: string; steps: Mitiga
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,4 @@
|
||||
|
||||
return infoA.portNum - infoB.portNum;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { ThreatStat } from "@/lib/api";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
|
||||
export const getSeverityBadge = (sev?: string | null) => {
|
||||
if (!sev) return <Badge variant="green">Unknown</Badge>;
|
||||
const s = sev.toLowerCase();
|
||||
if (s.includes("high") || s.includes("critical")) return <Badge variant="red">{sev}</Badge>;
|
||||
if (s.includes("med")) return <Badge variant="orange">{sev}</Badge>;
|
||||
if (s.includes("low")) return <Badge variant="yellow">{sev}</Badge>;
|
||||
return <Badge variant="blue">{sev}</Badge>;
|
||||
};
|
||||
|
||||
export function getThreatColumns(
|
||||
setSelectedIp: (ip: string) => void,
|
||||
setSelectedMac: (mac: string) => void,
|
||||
setSelectedThreat: (threat: ThreatStat) => void,
|
||||
highlightId: string | null
|
||||
): Column<ThreatStat>[] {
|
||||
return [
|
||||
{
|
||||
header: "Timestamp",
|
||||
className: "w-[14%] text-center",
|
||||
accessor: (row) => <TimestampCell timestamp={row.detected_at} showActiveStatus={false} />,
|
||||
},
|
||||
{ header: "#", className: "w-[4%] text-center", accessor: (_row, i) => i + 1 },
|
||||
{
|
||||
header: "Type",
|
||||
className: "w-[16%] text-center",
|
||||
accessor: (row) => (
|
||||
<div
|
||||
title={row.description || row.threat_type || "Unknown"}
|
||||
className="truncate w-full block cursor-help text-xs leading-tight"
|
||||
>
|
||||
{row.threat_type || "Unknown"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Severity",
|
||||
className: "w-[8%] text-center",
|
||||
accessor: (row) => getSeverityBadge(row.severity),
|
||||
},
|
||||
{
|
||||
header: "Source IP",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) =>
|
||||
row.ip_address ? (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.ip_address || "")}
|
||||
className="font-medium text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full text-center truncate"
|
||||
>
|
||||
{row.ip_address}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="Source IP is not available from security event data (only MAC Address was captured)">–</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Dest IP",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) =>
|
||||
row.dst_ip ? (
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.dst_ip || "")}
|
||||
className="font-mono text-xs text-blue-400 hover:text-blue-300 hover:underline focus:outline-none w-full text-center truncate"
|
||||
>
|
||||
{row.dst_ip}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="Dest IP is not available from this event data">–</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "MAC Addr",
|
||||
className: "w-[13%] text-center",
|
||||
accessor: (row) =>
|
||||
row.mac_address ? (
|
||||
<button
|
||||
onClick={() => setSelectedMac(row.mac_address || "")}
|
||||
className="text-xs bg-secondary/50 px-1.5 py-0.5 rounded text-muted-foreground hover:text-white hover:bg-secondary transition-colors font-mono focus:outline-none w-full text-center truncate"
|
||||
title={row.mac_address}
|
||||
>
|
||||
{row.mac_address}
|
||||
</button>
|
||||
) : "–",
|
||||
},
|
||||
{
|
||||
header: "App",
|
||||
className: "w-[9%] text-center",
|
||||
accessor: (row) => {
|
||||
const formatted = formatAppLabel(row.app_label);
|
||||
return formatted ? (
|
||||
<div title={formatted} className="truncate w-full block text-center cursor-help text-xs">
|
||||
{formatted}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="App is not available from this event">–</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Domain",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) => row.domain ? (
|
||||
<div title={row.domain} className="truncate w-full text-center text-xs cursor-help">
|
||||
{row.domain}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs" title="Domain is not available from this event">–</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Action",
|
||||
className: "w-[10%] text-center",
|
||||
accessor: (row) => (
|
||||
<button
|
||||
onClick={() => setSelectedThreat(row)}
|
||||
className="px-2 py-1 text-[11px] font-bold text-primary bg-primary/10 border border-primary/20 hover:bg-primary/20 hover:text-white rounded-lg transition-all shadow-sm cursor-pointer focus:outline-none leading-tight"
|
||||
>
|
||||
View Mitigation
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface AgentAppItem {
|
||||
app_id: number | null;
|
||||
app_label: string;
|
||||
category: string | null;
|
||||
download: number;
|
||||
upload: number;
|
||||
}
|
||||
|
||||
interface AgentAppsTabProps {
|
||||
topApps: AgentAppItem[];
|
||||
onSelectApp: (label: string) => void;
|
||||
}
|
||||
|
||||
export default function AgentAppsTab({ topApps, onSelectApp }: AgentAppsTabProps) {
|
||||
if (topApps.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm text-center py-10">No applications accessed by this agent.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{topApps.map((app) => {
|
||||
const appTotal = app.download + app.upload;
|
||||
return (
|
||||
<button
|
||||
key={app.app_id}
|
||||
onClick={() => onSelectApp(app.app_label)}
|
||||
className="flex items-center gap-3 p-3 bg-white/[0.03] hover:bg-white/[0.07] border border-border/50 rounded-xl transition-all group w-full text-left"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-card-foreground text-sm truncate group-hover:text-cyan-300 transition-colors">
|
||||
{app.app_label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{app.category || "Web"}</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-card-foreground font-semibold">{fmtBytes(appTotal)}</p>
|
||||
<span className="text-[10px] text-muted-foreground flex gap-2">
|
||||
<span className="text-cyan-400">DL: {fmtBytes(app.download)}</span>
|
||||
<span className="text-green-400">UL: {fmtBytes(app.upload)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600 group-hover:text-cyan-400 transition-colors" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className="fixed inset-0 z-[9999] 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-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "./sharedPdfStyles";
|
||||
|
||||
interface AgentPdfDocumentProps {
|
||||
data: any;
|
||||
agentLabel: string;
|
||||
agentUuid: string;
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
interface TableColumn {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right" | "left";
|
||||
cell: (row: any) => string | number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1000;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr: string | Date): string {
|
||||
if (!dateStr) return "Never";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch (e) {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 15 }}>
|
||||
<Text style={sharedStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
{columns.map((col, idx) => (
|
||||
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
|
||||
{col.header}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No Data</Text>
|
||||
</View>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
|
||||
{col.cell(row)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentPdfDocument({
|
||||
data,
|
||||
agentLabel,
|
||||
agentUuid,
|
||||
reportDateStr,
|
||||
}: AgentPdfDocumentProps) {
|
||||
const summary = data.summary || {};
|
||||
|
||||
// Sort by total bandwidth (download + upload) descending for PDF export
|
||||
const sortByBandwidth = (arr: any[]) =>
|
||||
[...arr].sort((a, b) =>
|
||||
((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) -
|
||||
((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0))
|
||||
);
|
||||
|
||||
const devices = sortByBandwidth(data.devices || []).slice(0, 50);
|
||||
const flows = sortByBandwidth(data.flows || []).slice(0, 50);
|
||||
const topApps = sortByBandwidth(data.top_apps || []).slice(0, 50);
|
||||
const macBw = sortByBandwidth(data.mac_bandwidth || []).slice(0, 50);
|
||||
// Security alerts: not sorted by bandwidth — keep original order
|
||||
const insecure = (data.security?.insecure_protocols || []).slice(0, 50);
|
||||
const reputation = (data.security?.ip_reputation || []).slice(0, 50);
|
||||
const events = (data.events || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Network Agent Report</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Agent Info */}
|
||||
<Text style={sharedStyles.sectionTitle}>Agent Identification</Text>
|
||||
<View style={sharedStyles.grid}>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "35%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Agent Name</Text>
|
||||
<Text style={sharedStyles.gridValue}>{agentLabel || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "65%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Agent UUID</Text>
|
||||
<Text style={sharedStyles.gridValue}>{agentUuid || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.gridCol}>
|
||||
<Text style={sharedStyles.gridLabel}>Active Devices</Text>
|
||||
<Text style={sharedStyles.gridValue}>{summary.total_devices ?? 0}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.gridCol}>
|
||||
<Text style={sharedStyles.gridLabel}>Active Flows</Text>
|
||||
<Text style={sharedStyles.gridValue}>{summary.active_flows ?? 0}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bandwidth Usage */}
|
||||
<Text style={sharedStyles.sectionTitle}>Bandwidth Metrics</Text>
|
||||
<View style={sharedStyles.bandwidthGrid}>
|
||||
<View style={sharedStyles.bandwidthCardCyan}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#115e59" }}>{formatBytes(summary.bandwidth_down)}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.bandwidthCardGreen}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{formatBytes(summary.bandwidth_up)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Top Apps */}
|
||||
<RenderPdfTable
|
||||
title="Top Applications"
|
||||
data={topApps}
|
||||
columns={[
|
||||
{ header: "Application", width: "40%", cell: (r) => r.app_label || "Unknown" },
|
||||
{ header: "Category", width: "20%", cell: (r) => r.category || "—" },
|
||||
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Monitored Devices */}
|
||||
<RenderPdfTable
|
||||
title="Monitored Devices"
|
||||
data={devices}
|
||||
columns={[
|
||||
{ header: "IP Address", width: "22%", cell: (r) => r.ip_address || "N/A" },
|
||||
{ header: "OS / Manufacturer", width: "28%", cell: (r) => r.os_label || r.manufacturer || "Unknown" },
|
||||
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
{ header: "Last Seen", width: "20%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Recent Network Flows */}
|
||||
<RenderPdfTable
|
||||
title="Recent Network Flows"
|
||||
data={flows}
|
||||
columns={[
|
||||
{ header: "Source IP", width: "20%", cell: (r) => r.src_ip || "—" },
|
||||
{ header: "Destination IP", width: "20%", cell: (r) => r.dst_ip || "—" },
|
||||
{ header: "Port", width: "10%", align: "center", cell: (r) => r.dst_port || "—" },
|
||||
{ header: "App / Protocol", width: "20%", cell: (r) => r.app_label || r.protocol || "Unknown" },
|
||||
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Insecure Protocols */}
|
||||
<RenderPdfTable
|
||||
title="Insecure Protocols"
|
||||
data={insecure}
|
||||
columns={[
|
||||
{ header: "Host IP", width: "25%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Protocol", width: "15%", cell: (r) => r.protocol || "—" },
|
||||
{ header: "Risk Level", width: "15%", cell: (r) => r.risk || "—" },
|
||||
{ header: "Destination", width: "25%", cell: (r) => r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? ""}` : "—" },
|
||||
{ header: "Bytes Traced", width: "20%", align: "right", cell: (r) => formatBytes((r.download ?? 0) + (r.upload ?? 0)) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* IP Reputation Log */}
|
||||
<RenderPdfTable
|
||||
title="IP Reputation Log"
|
||||
data={reputation}
|
||||
columns={[
|
||||
{ header: "Remote IP", width: "30%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Local IP", width: "30%", cell: (r) => r.local_ip || "—" },
|
||||
{ header: "Reputation", width: "20%", cell: (r) => r.reputation || "—" },
|
||||
{ header: "Risk Score", width: "20%", align: "right", cell: (r) => r.score ?? "—" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Security Events */}
|
||||
<RenderPdfTable
|
||||
title="Security Events Log"
|
||||
data={events}
|
||||
columns={[
|
||||
{ header: "Event Type", width: "20%", cell: (r) => r.event_type || "—" },
|
||||
{ header: "Severity", width: "15%", align: "center", cell: (r) => r.severity || "—" },
|
||||
{ header: "IP Address", width: "20%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Description", width: "25%", cell: (r) => r.description || "—" },
|
||||
{ header: "Event Time", width: "20%", cell: (r) => r.event_at ? formatIndonesiaTime(r.event_at) : "—" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* MAC Bandwidth */}
|
||||
<RenderPdfTable
|
||||
title="MAC Bandwidth Distribution"
|
||||
data={macBw}
|
||||
columns={[
|
||||
{ header: "MAC Address", width: "30%", cell: (r) => r.mac_address || "—" },
|
||||
{ header: "Manufacturer", width: "30%", cell: (r) => r.manufacturer || "Unknown" },
|
||||
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export function AppDetailModal({ appLabel, favicon, category, agentUuid, onClose
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className="fixed inset-0 z-[9999] 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-3xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "./sharedPdfStyles";
|
||||
|
||||
interface AppPdfDocumentProps {
|
||||
data: any;
|
||||
appLabel: string;
|
||||
category: string;
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
interface TableColumn {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right" | "left";
|
||||
cell: (row: any) => string | number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1000;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr: string | Date): string {
|
||||
if (!dateStr) return "Never";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch (e) {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 15 }}>
|
||||
<Text style={sharedStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
{columns.map((col, idx) => (
|
||||
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableHeaderCell, textAlign: col.align || "left" }}>
|
||||
{col.header}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No Data</Text>
|
||||
</View>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={sharedStyles.tableRow} wrap={false}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: col.align || "left" }}>
|
||||
{col.cell(row)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppPdfDocument({
|
||||
data,
|
||||
appLabel,
|
||||
category,
|
||||
reportDateStr,
|
||||
}: AppPdfDocumentProps) {
|
||||
// Sort by total bandwidth (download + upload) descending for PDF export
|
||||
const sortByBandwidth = (arr: any[]) =>
|
||||
[...arr].sort((a, b) =>
|
||||
((b.download ?? 0) + (b.upload ?? 0)) - ((a.download ?? 0) + (a.upload ?? 0))
|
||||
);
|
||||
|
||||
const topIps = sortByBandwidth(data.top_ips || []).slice(0, 50);
|
||||
const agents = sortByBandwidth(data.agent_scorecard || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Application Activity Report</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Application Details */}
|
||||
<Text style={sharedStyles.sectionTitle}>Application Summary</Text>
|
||||
<View style={sharedStyles.grid}>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "50%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Application Name</Text>
|
||||
<Text style={sharedStyles.gridValue}>{appLabel}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "50%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Category</Text>
|
||||
<Text style={sharedStyles.gridValue}>{category || "General Web"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bandwidth Usage */}
|
||||
<Text style={sharedStyles.sectionTitle}>Traffic Metrics</Text>
|
||||
<View style={sharedStyles.bandwidthGrid}>
|
||||
<View style={sharedStyles.bandwidthCardCyan}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#115e59" }}>{formatBytes(data.total_download)}</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.bandwidthCardGreen}>
|
||||
<Text style={{ ...sharedStyles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text>
|
||||
<Text style={{ ...sharedStyles.bandwidthValue, color: "#166534" }}>{formatBytes(data.total_upload)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Top Users */}
|
||||
<RenderPdfTable
|
||||
title="Top User Devices"
|
||||
data={topIps}
|
||||
columns={[
|
||||
{ header: "IP Address", width: "30%", cell: (r) => r.ip_address || "—" },
|
||||
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
{ header: "Flows", width: "10%", align: "center", cell: (r) => r.flow_count ?? 0 },
|
||||
{ header: "Last Seen", width: "20%", cell: (r) => r.last_seen ? formatIndonesiaTime(r.last_seen) : "Never" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Top Agents */}
|
||||
<RenderPdfTable
|
||||
title="Agent Telemetry Distribution"
|
||||
data={agents}
|
||||
columns={[
|
||||
{ header: "Agent Node", width: "50%", cell: (r) => r.agent_label || "Unknown Agent" },
|
||||
{ header: "Download", width: "20%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "20%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
{ header: "Flows", width: "10%", align: "center", cell: (r) => r.flow_count ?? 0 },
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "red" | "orange" | "yellow" | "green" | "blue" | "cyan";
|
||||
}
|
||||
|
||||
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
"border-primary/20 bg-primary/10 text-primary": variant === "default",
|
||||
"border-red-500/20 bg-red-500/10 text-red-500": variant === "red",
|
||||
"border-orange-500/20 bg-orange-500/10 text-orange-500": variant === "orange",
|
||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-500": variant === "yellow",
|
||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-500": variant === "green",
|
||||
"border-blue-500/20 bg-blue-500/10 text-blue-500": variant === "blue",
|
||||
"border-cyan-500/20 bg-cyan-500/10 text-cyan-500": variant === "cyan",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "red" | "orange" | "yellow" | "green" | "blue" | "cyan";
|
||||
}
|
||||
|
||||
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
"border-primary/20 bg-primary/10 text-primary": variant === "default",
|
||||
"border-red-500/20 bg-red-500/10 text-red-500": variant === "red",
|
||||
"border-orange-500/20 bg-orange-500/10 text-orange-500": variant === "orange",
|
||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-500": variant === "yellow",
|
||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-500": variant === "green",
|
||||
"border-blue-500/20 bg-blue-500/10 text-blue-500": variant === "blue",
|
||||
"border-cyan-500/20 bg-cyan-500/10 text-cyan-500": variant === "cyan",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+68
-68
@@ -1,68 +1,68 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card/80 backdrop-blur-xl text-card-foreground shadow-sm hover:shadow-primary/5 hover:border-primary/20 transition-all duration-300 ease-in-out hover:-translate-y-0.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col space-y-1.5 p-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("p-6 pt-0", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card/80 backdrop-blur-xl text-card-foreground shadow-sm hover:shadow-primary/5 hover:border-primary/20 transition-all duration-300 ease-in-out hover:-translate-y-0.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col space-y-1.5 p-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("p-6 pt-0", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
+129
-118
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Search, Download } from "lucide-react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Search, Download, ChevronDown, FileText, FileSpreadsheet } from "lucide-react";
|
||||
import { CsvExportOptions, downloadCsv } from "./DataTableCsvHelper";
|
||||
import { DataTablePagination } from "./DataTablePagination";
|
||||
|
||||
export interface Column<T> {
|
||||
header: React.ReactNode;
|
||||
@@ -9,12 +11,7 @@ export interface Column<T> {
|
||||
className?: string;
|
||||
sortable?: boolean;
|
||||
sortAccessor?: (row: T) => number | string | null | undefined;
|
||||
}
|
||||
|
||||
export interface CsvExportOptions<T> {
|
||||
filename: string;
|
||||
headers: string[];
|
||||
rowSerializer: (row: T) => (string | number | null | undefined)[];
|
||||
tooltip?: (row: T) => string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
@@ -26,27 +23,12 @@ interface DataTableProps<T> {
|
||||
getRowClassName?: (row: T) => string;
|
||||
pageSize?: number;
|
||||
csvExport?: CsvExportOptions<T>;
|
||||
pdfExport?: { onExport: () => void; isExporting?: boolean; };
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
function escapeCsvCell(val: string | number | null | undefined): string {
|
||||
const str = val == null ? "" : String(val);
|
||||
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function downloadCsv(filename: string, rows: (string | number | null | undefined)[][]): void {
|
||||
const bom = "\uFEFF";
|
||||
const csv = bom + rows.map(row => row.map(escapeCsvCell).join(",")).join("\r\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
serverSide?: boolean;
|
||||
totalCount?: number;
|
||||
currentPage?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
@@ -58,61 +40,71 @@ export function DataTable<T>({
|
||||
getRowClassName,
|
||||
pageSize = 50,
|
||||
csvExport,
|
||||
pdfExport,
|
||||
onRowClick,
|
||||
serverSide = false,
|
||||
totalCount = 0,
|
||||
currentPage = 1,
|
||||
onPageChange,
|
||||
}: DataTableProps<T>) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [localCurrentPage, setLocalCurrentPage] = useState(1);
|
||||
const [sortConfig, setSortConfig] = useState<{ key: number; direction: 'asc' | 'desc' } | null>(null);
|
||||
|
||||
const handleSort = (columnIndex: number) => {
|
||||
let direction: 'asc' | 'desc' = 'asc';
|
||||
if (sortConfig && sortConfig.key === columnIndex && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key: columnIndex, direction });
|
||||
const activePage = serverSide ? currentPage : localCurrentPage;
|
||||
const setActivePage = serverSide ? (onPageChange || (() => {})) : setLocalCurrentPage;
|
||||
|
||||
const handleSort = (key: number) => {
|
||||
const direction = sortConfig?.key === key && sortConfig.direction === 'asc' ? 'desc' : 'asc';
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
let processedData = [...data];
|
||||
|
||||
if (searchFilter && query.trim()) {
|
||||
if (!serverSide && searchFilter && query.trim()) {
|
||||
processedData = processedData.filter(row => searchFilter(row, query.trim()));
|
||||
}
|
||||
|
||||
if (sortConfig !== null) {
|
||||
if (!serverSide && sortConfig !== null) {
|
||||
const column = columns[sortConfig.key];
|
||||
if (column && column.sortable && column.sortAccessor) {
|
||||
processedData.sort((a, b) => {
|
||||
const valA = column.sortAccessor!(a);
|
||||
const valB = column.sortAccessor!(b);
|
||||
const valA = column.sortAccessor!(a), valB = column.sortAccessor!(b);
|
||||
if (valA === valB) return 0;
|
||||
if (valA === null || valA === undefined) return 1;
|
||||
if (valB === null || valB === undefined) return -1;
|
||||
if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
if (valA == null) return 1;
|
||||
if (valB == null) return -1;
|
||||
const asc = sortConfig.direction === 'asc';
|
||||
return valA < valB ? (asc ? -1 : 1) : (asc ? 1 : -1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const filteredData = processedData;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredData.length / pageSize));
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + pageSize);
|
||||
const totalItems = serverSide ? totalCount : filteredData.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
const startIndex = (activePage - 1) * pageSize;
|
||||
const paginatedData = serverSide ? filteredData : filteredData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const exportRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => exportRef.current && !exportRef.current.contains(e.target as Node) && setExportOpen(false);
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
}, []);
|
||||
|
||||
const handleCsvExport = () => {
|
||||
if (!csvExport) return;
|
||||
const rows = [
|
||||
csvExport.headers,
|
||||
...filteredData.map(row => csvExport.rowSerializer(row)),
|
||||
];
|
||||
downloadCsv(csvExport.filename, rows);
|
||||
downloadCsv(csvExport.filename, [csvExport.headers, ...filteredData.map(csvExport.rowSerializer)]);
|
||||
setExportOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
{searchFilter && (
|
||||
{!serverSide && searchFilter && (
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground transition-colors group-focus-within:text-primary" />
|
||||
<input
|
||||
@@ -121,45 +113,75 @@ export function DataTable<T>({
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
setActivePage(1);
|
||||
}}
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{csvExport && (
|
||||
<button
|
||||
onClick={handleCsvExport}
|
||||
title={`Export ${filteredData.length} rows to CSV`}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/70 hover:text-emerald-300 transition-all shadow-sm whitespace-nowrap"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export CSV ({filteredData.length})
|
||||
</button>
|
||||
{(csvExport || pdfExport) && (
|
||||
<div className="relative" ref={exportRef}>
|
||||
<button
|
||||
onClick={() => setExportOpen(prev => !prev)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/70 hover:text-emerald-300 transition-all shadow-sm whitespace-nowrap"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Export ({totalItems})
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${exportOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{exportOpen && (
|
||||
<div className="absolute right-0 mt-1.5 w-44 rounded-xl border border-border/60 bg-slate-900/95 backdrop-blur-xl shadow-xl z-50 overflow-hidden">
|
||||
{csvExport && (
|
||||
<button
|
||||
onClick={handleCsvExport}
|
||||
className="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||
>
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
Export CSV
|
||||
</button>
|
||||
)}
|
||||
{pdfExport && (
|
||||
<button
|
||||
onClick={() => { pdfExport.onExport(); setExportOpen(false); }}
|
||||
disabled={pdfExport.isExporting}
|
||||
className="flex items-center gap-2.5 w-full px-4 py-2.5 text-sm text-blue-400 hover:bg-blue-500/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
{pdfExport.isExporting ? 'Generating...' : 'Export PDF'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/50 backdrop-blur-xl shadow-sm overflow-hidden">
|
||||
<div className="w-full max-h-[400px] overflow-y-auto custom-scrollbar [&::-webkit-scrollbar-track]:mt-[48px]">
|
||||
<table className="w-full text-sm text-left table-fixed">
|
||||
<table className="w-full text-sm text-center table-fixed">
|
||||
<thead className="bg-slate-900 text-muted-foreground border-b border-border/50 sticky top-0 z-20">
|
||||
<tr>
|
||||
{columns.map((col, i) => (
|
||||
<th
|
||||
key={i}
|
||||
onClick={() => col.sortable && handleSort(i)}
|
||||
className={`px-4 py-3.5 h-[48px] font-medium tracking-wide whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors group select-none" : ""}`}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${col.className?.includes("text-") && !col.className?.includes("text-center") ? "" : "justify-center"}`}>
|
||||
{col.header}
|
||||
{col.sortable && (
|
||||
<div className="flex flex-col text-[10px] opacity-40 group-hover:opacity-100 transition-opacity">
|
||||
<span className={`${sortConfig?.key === i && sortConfig.direction === 'asc' ? 'text-primary opacity-100' : ''}`}>▲</span>
|
||||
<span className={`-mt-1 ${sortConfig?.key === i && sortConfig.direction === 'desc' ? 'text-primary opacity-100' : ''}`}>▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
{columns.map((col, i) => {
|
||||
const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-'));
|
||||
const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` };
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
onClick={() => col.sortable && handleSort(i)}
|
||||
className={`px-4 py-3.5 h-[48px] font-medium tracking-wide text-center ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors group select-none" : ""}`}
|
||||
style={widthStyle}
|
||||
>
|
||||
<div className="flex items-center gap-1 justify-center">
|
||||
{col.header}
|
||||
{col.sortable && (
|
||||
<div className="flex flex-col text-[10px] opacity-40 group-hover:opacity-100 transition-opacity">
|
||||
<span className={`${sortConfig?.key === i && sortConfig.direction === 'asc' ? 'text-primary opacity-100' : ''}`}>▲</span>
|
||||
<span className={`-mt-1 ${sortConfig?.key === i && sortConfig.direction === 'desc' ? 'text-primary opacity-100' : ''}`}>▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
@@ -184,7 +206,6 @@ export function DataTable<T>({
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Search className="h-8 w-8 mb-3 opacity-20" />
|
||||
<span className="text-sm font-medium">No data found</span>
|
||||
{query && <span className="text-xs mt-1 opacity-70">Try adjusting your search query</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -195,11 +216,24 @@ export function DataTable<T>({
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`hover:bg-primary/5 border-b border-border/50 transition-colors duration-200 group h-[48px] ${onRowClick ? "cursor-pointer hover:bg-primary/10" : ""} ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground transition-colors truncate whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
</td>
|
||||
))}
|
||||
{columns.map((col, j) => {
|
||||
const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-'));
|
||||
const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` };
|
||||
const content = col.accessor(row, (activePage - 1) * pageSize + i);
|
||||
const tooltipText = col.tooltip
|
||||
? col.tooltip(row)
|
||||
: (typeof content === 'string' || typeof content === 'number' ? String(content) : undefined);
|
||||
return (
|
||||
<td
|
||||
key={j}
|
||||
className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground text-center transition-colors ${col.className || ""}`}
|
||||
style={widthStyle}
|
||||
title={tooltipText}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -207,37 +241,14 @@ export function DataTable<T>({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredData.length > pageSize && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{startIndex + 1}</span> to{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(startIndex + pageSize, filteredData.length)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{filteredData.length}</span> results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div className="text-sm text-muted-foreground font-medium px-2">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DataTablePagination
|
||||
totalItems={totalItems}
|
||||
pageSize={pageSize}
|
||||
currentPage={activePage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setActivePage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface CsvExportOptions<T> {
|
||||
filename: string;
|
||||
headers: string[];
|
||||
rowSerializer: (row: T) => (string | number | null | undefined)[];
|
||||
}
|
||||
|
||||
export function escapeCsvCell(val: string | number | null | undefined): string {
|
||||
const str = val == null ? "" : String(val);
|
||||
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
|
||||
return `"${str.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
export function downloadCsv(filename: string, rows: (string | number | null | undefined)[][]): void {
|
||||
const bom = "\uFEFF";
|
||||
const csv = bom + rows.map(row => row.map(escapeCsvCell).join(",")).join("\r\n");
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface DataTablePaginationProps {
|
||||
totalItems: number;
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function DataTablePagination({
|
||||
totalItems,
|
||||
pageSize,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: DataTablePaginationProps) {
|
||||
if (totalItems <= pageSize) return null;
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30 flex-wrap gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground">{startIndex + 1}</span> to{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(startIndex + pageSize, totalItems)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-foreground">{totalItems}</span> results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50 cursor-pointer"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<div className="text-sm text-muted-foreground font-medium px-2">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50 cursor-pointer"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function DateRangePicker({
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-900 border border-border/50 rounded-xl shadow-2xl z-50 p-4 min-w-[260px]">
|
||||
<p className="text-xs text-muted-foreground mb-3 font-medium">
|
||||
Filter by Date Range
|
||||
Filter Threats by Date
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
@@ -119,6 +119,17 @@ export function DateRangePicker({
|
||||
>
|
||||
7 Days
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 29);
|
||||
onChange(d.toISOString().slice(0, 10), today);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex-1 px-2 py-1.5 text-xs bg-secondary/50 text-white border border-border/50 rounded-lg hover:bg-secondary transition-colors"
|
||||
>
|
||||
30 Days
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClear();
|
||||
@@ -133,4 +144,4 @@ export function DateRangePicker({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchDeviceDetails, DeviceDetails, DeviceStat } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, X, Monitor, Activity, Shield, Box, Server, Cpu, Clock, HardDrive, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Globe, Link2 } from "lucide-react";
|
||||
import ActiveDurationDisplay from "./ActiveDurationDisplay";
|
||||
|
||||
import DeviceAppsTab from "./DeviceAppsTab";
|
||||
import DeviceProtocolsTab from "./DeviceProtocolsTab";
|
||||
import DeviceDomainsTab from "./DeviceDomainsTab";
|
||||
import DeviceFlowsTab from "./DeviceFlowsTab";
|
||||
import DeviceThreatsTab from "./DeviceThreatsTab";
|
||||
import DeviceIdentityCard from "./DeviceIdentityCard";
|
||||
|
||||
interface Props {
|
||||
ip: string;
|
||||
@@ -21,6 +20,15 @@ interface Props {
|
||||
|
||||
type Tab = "info" | "apps" | "protocols" | "domains" | "flows" | "threats";
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "info", label: "Overview", icon: Monitor },
|
||||
{ id: "apps", label: "Applications", icon: Box },
|
||||
{ id: "protocols", label: "Protocols", icon: Link2 },
|
||||
{ id: "domains", label: "Domains / Web", icon: Globe },
|
||||
{ id: "flows", label: "Network Flows", icon: Activity },
|
||||
{ id: "threats", label: "Threats", icon: Shield }
|
||||
];
|
||||
|
||||
export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props) {
|
||||
const [data, setData] = useState<DeviceDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -54,14 +62,35 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
loadData(false);
|
||||
}, [ip, mac]);
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "info", label: "Overview", icon: Monitor },
|
||||
{ id: "apps", label: "Applications", icon: Box },
|
||||
{ id: "protocols", label: "Protocols", icon: Link2 },
|
||||
{ id: "domains", label: "Domains / Web", icon: Globe },
|
||||
{ id: "flows", label: "Network Flows", icon: Activity },
|
||||
{ id: "threats", label: "Threats", icon: Shield }
|
||||
];
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const exportToPdf = async () => {
|
||||
if (!data) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { pdf } = await import("@react-pdf/renderer");
|
||||
const { default: DevicePdfDocument } = await import("./DevicePdfDocument");
|
||||
const reportDateStr = new Date().toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta", day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
}) + " WIB";
|
||||
|
||||
const doc = <DevicePdfDocument data={data} ip={ip || data.ip_address || ""} mac={mac || data.mac_address || ""} deviceData={deviceData} reportDateStr={reportDateStr} />;
|
||||
|
||||
const blob = await pdf(doc).toBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `Device_DPI_Telemetry_Report_${ip || data.ip_address || "device"}.pdf`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Failed to export PDF:", err);
|
||||
alert("Failed to generate PDF report. Please try again.");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
@@ -84,7 +113,7 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-card-foreground tracking-tight">
|
||||
{ip || data?.ip_address || (isLoading ? 'Resolving IP...' : 'No IP Address')}
|
||||
{ip || data?.ip_address || 'Resolving IP...'}
|
||||
</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 animate-spin" />}
|
||||
</div>
|
||||
@@ -101,9 +130,25 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleClose} className="p-2.5 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{data && (
|
||||
<button
|
||||
disabled={isExporting}
|
||||
onClick={exportToPdf}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToLine className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Export PDF
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleClose} className="p-2.5 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -172,29 +217,12 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6">
|
||||
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" /> Identity & Metadata
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Cpu className="w-3.5 h-3.5" /> OS</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Monitor className="w-3.5 h-3.5" /> Type</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><HardDrive className="w-3.5 h-3.5" /> Manufacturer</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Clock className="w-3.5 h-3.5" /> Last Seen</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeviceIdentityCard
|
||||
osLabel={data.os_label || deviceData?.os_label || ""}
|
||||
deviceType={data.device_type || deviceData?.device_type || ""}
|
||||
manufacturer={data.manufacturer || deviceData?.manufacturer || ""}
|
||||
lastSeen={data.last_seen || deviceData?.last_seen || ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Server, Cpu, Monitor, HardDrive, Clock } from "lucide-react";
|
||||
|
||||
interface DeviceIdentityCardProps {
|
||||
osLabel: string;
|
||||
deviceType: string;
|
||||
manufacturer: string;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export default function DeviceIdentityCard({
|
||||
osLabel,
|
||||
deviceType,
|
||||
manufacturer,
|
||||
lastSeen,
|
||||
}: DeviceIdentityCardProps) {
|
||||
return (
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6 text-xs">
|
||||
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" /> Identity & Metadata
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5">
|
||||
<Cpu className="w-3.5 h-3.5" /> OS
|
||||
</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{osLabel || "Unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5">
|
||||
<Monitor className="w-3.5 h-3.5" /> Type
|
||||
</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{deviceType || "Unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5">
|
||||
<HardDrive className="w-3.5 h-3.5" /> Manufacturer
|
||||
</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{manufacturer || "Unknown"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5">
|
||||
<Clock className="w-3.5 h-3.5" /> Last Seen
|
||||
</p>
|
||||
<p className="text-card-foreground font-medium text-sm">
|
||||
{lastSeen ? new Date(lastSeen).toLocaleString() : "Never"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { DeviceDetails, DeviceStat } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Server, Cpu, Monitor, HardDrive, Clock, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import ActiveDurationDisplay from "./ActiveDurationDisplay";
|
||||
|
||||
interface DeviceOverviewTabProps {
|
||||
data: DeviceDetails;
|
||||
deviceData?: DeviceStat;
|
||||
}
|
||||
|
||||
export default function DeviceOverviewTab({ data, deviceData }: DeviceOverviewTabProps) {
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in text-xs">
|
||||
<ActiveDurationDisplay firstSeen={data.first_seen || undefined} lastSeen={data.last_seen || undefined} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-6 bg-gradient-to-br from-cyan-500/5 dark:from-cyan-500/10 to-transparent border border-cyan-100 dark:border-cyan-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowDownToLine className="w-16 h-16 text-cyan-500 dark:text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4" /> Total Download
|
||||
</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-br from-emerald-500/5 dark:from-emerald-500/10 to-transparent border border-emerald-100 dark:border-emerald-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowUpFromLine className="w-16 h-16 text-emerald-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowUpFromLine className="w-4 h-4" /> Total Upload
|
||||
</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6">
|
||||
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" /> Identity & Metadata
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Cpu className="w-3.5 h-3.5" /> OS</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.os_label || deviceData?.os_label || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Monitor className="w-3.5 h-3.5" /> Type</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.device_type || deviceData?.device_type || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><HardDrive className="w-3.5 h-3.5" /> Manufacturer</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.manufacturer || deviceData?.manufacturer || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Clock className="w-3.5 h-3.5" /> Last Seen</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.last_seen || deviceData?.last_seen ? new Date(data.last_seen || deviceData?.last_seen || '').toLocaleString() : 'Never'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { styles } from "./DevicePdfStyles";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
|
||||
interface DevicePdfDocumentProps {
|
||||
data: any;
|
||||
ip: string;
|
||||
mac: string;
|
||||
deviceData: any;
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
interface TableColumn {
|
||||
header: string;
|
||||
width: string;
|
||||
align?: "center" | "right" | "left";
|
||||
cell: (row: any) => string | number;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1000;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr: string | Date): string {
|
||||
if (!dateStr) return "Never";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch (e) {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
function RenderPdfTable({ title, columns, data }: { title: string; columns: TableColumn[]; data: any[] }) {
|
||||
return (
|
||||
<View style={{ marginBottom: 15 }}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.table}>
|
||||
<View style={styles.tableHeader}>
|
||||
{columns.map((col, idx) => (
|
||||
<View key={idx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...styles.tableHeaderCell, textAlign: col.align || "left" }}>
|
||||
{col.header}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{data.length === 0 ? (
|
||||
<View style={styles.tableRow}>
|
||||
<Text style={styles.emptyText}>No Data</Text>
|
||||
</View>
|
||||
) : (
|
||||
data.map((row, rowIdx) => (
|
||||
<View key={rowIdx} style={styles.tableRow} wrap={false}>
|
||||
{columns.map((col, colIdx) => (
|
||||
<View key={colIdx} style={{ width: col.width, paddingRight: 6 }}>
|
||||
<Text style={{ ...styles.tableCell, textAlign: col.align || "left" }}>
|
||||
{col.cell(row)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DevicePdfDocument({
|
||||
data,
|
||||
ip,
|
||||
mac,
|
||||
deviceData,
|
||||
reportDateStr,
|
||||
}: DevicePdfDocumentProps) {
|
||||
// Sort by total bandwidth (download + upload) descending for PDF export
|
||||
const sortByBandwidth = (arr: any[]) =>
|
||||
[...arr].sort((a, b) =>
|
||||
((b.download ?? b.bytes_download ?? 0) + (b.upload ?? b.bytes_upload ?? 0)) -
|
||||
((a.download ?? a.bytes_download ?? 0) + (a.upload ?? a.bytes_upload ?? 0))
|
||||
);
|
||||
|
||||
const topApps = sortByBandwidth(data.apps || []).slice(0, 50);
|
||||
const topProtocols = sortByBandwidth(data.protocols || []).slice(0, 50);
|
||||
const topDomains = sortByBandwidth(data.domains || []).slice(0, 50);
|
||||
// Threats: sorted by detected_at descending (newest first), not by bandwidth
|
||||
const detectedThreats = [...(data.threats || [])]
|
||||
.sort((a, b) => new Date(b.detected_at || 0).getTime() - new Date(a.detected_at || 0).getTime())
|
||||
.slice(0, 50);
|
||||
const flows = sortByBandwidth(data.flows || []).slice(0, 50);
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={styles.logoSubtext}>Security & Traffic Analytics Dashboard</Text>
|
||||
</View>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title}>Device Analysis Report</Text>
|
||||
<Text style={styles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Device Information */}
|
||||
<Text style={styles.sectionTitle}>Device Identification</Text>
|
||||
<View style={styles.grid}>
|
||||
<View style={styles.gridCol}>
|
||||
<Text style={styles.gridLabel}>IP Address</Text>
|
||||
<Text style={styles.gridValue}>{ip || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={styles.gridCol}>
|
||||
<Text style={styles.gridLabel}>MAC Address</Text>
|
||||
<Text style={styles.gridValue}>{mac || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.gridCol, width: "50%" }}>
|
||||
<Text style={styles.gridLabel}>Device OS</Text>
|
||||
<Text style={styles.gridValue}>{data.os_label || deviceData?.os_label || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={styles.gridCol}>
|
||||
<Text style={styles.gridLabel}>Manufacturer</Text>
|
||||
<Text style={styles.gridValue}>{data.manufacturer || deviceData?.manufacturer || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={styles.gridCol}>
|
||||
<Text style={styles.gridLabel}>Source Agent</Text>
|
||||
<Text style={styles.gridValue}>{data.agent_label || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={{ ...styles.gridCol, width: "50%" }}>
|
||||
<Text style={styles.gridLabel}>Last Seen</Text>
|
||||
<Text style={styles.gridValue}>
|
||||
{data.last_seen || deviceData?.last_seen
|
||||
? formatIndonesiaTime(data.last_seen || deviceData?.last_seen)
|
||||
: "Never"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bandwidth Usage */}
|
||||
<Text style={styles.sectionTitle}>Bandwidth Metrics</Text>
|
||||
<View style={styles.bandwidthGrid}>
|
||||
<View style={styles.bandwidthCardCyan}>
|
||||
<Text style={{ ...styles.bandwidthLabel, color: "#0d9488" }}>TOTAL DOWNLOAD</Text>
|
||||
<Text style={{ ...styles.bandwidthValue, color: "#115e59" }}>{formatBytes(data.total_download)}</Text>
|
||||
</View>
|
||||
<View style={styles.bandwidthCardGreen}>
|
||||
<Text style={{ ...styles.bandwidthLabel, color: "#16a34a" }}>TOTAL UPLOAD</Text>
|
||||
<Text style={{ ...styles.bandwidthValue, color: "#166534" }}>{formatBytes(data.total_upload)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Security Threats Table */}
|
||||
<RenderPdfTable
|
||||
title={`Security Threats (${detectedThreats.length})`}
|
||||
data={detectedThreats}
|
||||
columns={[
|
||||
{ header: "Time", width: "25%", cell: (r) => r.detected_at ? formatIndonesiaTime(r.detected_at) : "N/A" },
|
||||
{ header: "Threat Type / Description", width: "35%", cell: (r) => r.threat_type || "Security Alert" },
|
||||
{ header: "Severity", width: "15%", align: "center", cell: (r) => (r.severity || "UNKNOWN").toUpperCase() },
|
||||
{ header: "Target IP", width: "25%", cell: (r) => r.dst_ip || r.ip_address || "—" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Top Applications */}
|
||||
<RenderPdfTable
|
||||
title="Top Applications (By Traffic)"
|
||||
data={topApps}
|
||||
columns={[
|
||||
{ header: "Application", width: "50%", cell: (r) => r.app_label || "Unknown" },
|
||||
{ header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Top Network Protocols */}
|
||||
<RenderPdfTable
|
||||
title="Top Protocols"
|
||||
data={topProtocols}
|
||||
columns={[
|
||||
{ header: "Protocol / Port", width: "50%", cell: (r) => formatAppLabel(r.app_label) },
|
||||
{ header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Top Visited Domains */}
|
||||
<RenderPdfTable
|
||||
title="Top Domains"
|
||||
data={topDomains}
|
||||
columns={[
|
||||
{ header: "Domain", width: "50%", cell: (r) => r.domain || "—" },
|
||||
{ header: "Download", width: "25%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "25%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Recent Network Flows */}
|
||||
<RenderPdfTable
|
||||
title="Recent Network Flows"
|
||||
data={flows}
|
||||
columns={[
|
||||
{ header: "Source IP", width: "20%", cell: (r) => r.src_ip || "—" },
|
||||
{ header: "Destination IP", width: "20%", cell: (r) => r.dst_ip || "—" },
|
||||
{ header: "Port", width: "10%", align: "center", cell: (r) => r.dst_port || "—" },
|
||||
{ header: "App / Protocol", width: "20%", cell: (r) => r.app_label || r.protocol || "Unknown" },
|
||||
{ header: "Download", width: "15%", align: "right", cell: (r) => formatBytes(r.download) },
|
||||
{ header: "Upload", width: "15%", align: "right", cell: (r) => formatBytes(r.upload) },
|
||||
]}
|
||||
/>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 30,
|
||||
fontSize: 9,
|
||||
fontFamily: "Helvetica",
|
||||
color: "#334155",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1.5,
|
||||
borderBottomColor: "#3b82f6",
|
||||
paddingBottom: 12,
|
||||
marginBottom: 15,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#1e3a8a",
|
||||
},
|
||||
logoSubtext: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
marginTop: 2,
|
||||
},
|
||||
titleContainer: {
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
color: "#0f172a",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
marginTop: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
color: "#1e3a8a",
|
||||
backgroundColor: "#f8fafc",
|
||||
padding: "4 8",
|
||||
marginTop: 15,
|
||||
marginBottom: 8,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: "#3b82f6",
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
marginBottom: 10,
|
||||
},
|
||||
gridCol: {
|
||||
width: "25%",
|
||||
marginBottom: 8,
|
||||
},
|
||||
gridLabel: {
|
||||
fontSize: 7,
|
||||
color: "#64748b",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 2,
|
||||
},
|
||||
gridValue: {
|
||||
fontSize: 8.5,
|
||||
fontWeight: "bold",
|
||||
color: "#0f172a",
|
||||
},
|
||||
bandwidthGrid: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
},
|
||||
bandwidthCardCyan: {
|
||||
width: "48%",
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e2e8f0",
|
||||
backgroundColor: "#f0fdfa",
|
||||
},
|
||||
bandwidthCardGreen: {
|
||||
width: "48%",
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e2e8f0",
|
||||
backgroundColor: "#f0fdf4",
|
||||
},
|
||||
bandwidthLabel: {
|
||||
fontSize: 7.5,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 3,
|
||||
},
|
||||
bandwidthValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
marginBottom: 10,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: "#f1f5f9",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#cbd5e1",
|
||||
padding: "4 6",
|
||||
},
|
||||
tableHeaderCell: {
|
||||
fontSize: 7.5,
|
||||
fontWeight: "bold",
|
||||
color: "#475569",
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f1f5f9",
|
||||
padding: "4 6",
|
||||
},
|
||||
tableCell: {
|
||||
fontSize: 7.5,
|
||||
color: "#334155",
|
||||
},
|
||||
alertBox: {
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#fef2f2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#fecaca",
|
||||
marginBottom: 10,
|
||||
},
|
||||
alertTitle: {
|
||||
fontSize: 8.5,
|
||||
fontWeight: "bold",
|
||||
color: "#991b1b",
|
||||
marginBottom: 2,
|
||||
},
|
||||
alertText: {
|
||||
fontSize: 7.5,
|
||||
color: "#7f1d1d",
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
fontStyle: "italic",
|
||||
padding: 6,
|
||||
},
|
||||
});
|
||||
@@ -98,4 +98,4 @@ export function DropdownFilterSelect({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
HelpCircle,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
BookOpen,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTenantConfig } from "@/contexts/TenantConfigContext";
|
||||
import { FAQS } from "@/lib/helpContent";
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { HelpCircle, X, Search, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTenantConfig } from '@/contexts/TenantConfigContext';
|
||||
import { FAQS } from '@/lib/helpContent';
|
||||
|
||||
// ─── Expandable FAQ Item ───────────────────────────────────────────────────────
|
||||
const FAQItem = ({ question, answer }: { question: string; answer: string }) => {
|
||||
const FAQItem = ({ question, answer }: { question: string, answer: string }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/50 py-3 last:border-0">
|
||||
<button
|
||||
<div className="border-b border-border/50 py-3">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex w-full items-center justify-between text-left focus:outline-none gap-3"
|
||||
className="flex w-full items-center justify-between text-left focus:outline-none"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground leading-snug">{question}</span>
|
||||
{isOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground">{question}</span>
|
||||
{isOpen ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-2 text-sm text-muted-foreground leading-relaxed">{answer}</p>
|
||||
<p className="pt-2 text-sm text-muted-foreground leading-relaxed">
|
||||
{answer}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -48,10 +36,19 @@ const FAQItem = ({ question, answer }: { question: string; answer: string }) =>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main FAB Component ────────────────────────────────────────────────────────
|
||||
export function HelpCenterFAB() {
|
||||
const { tenantConfig } = useTenantConfig();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { tenantConfig } = useTenantConfig();
|
||||
|
||||
const brandName = tenantConfig?.brandName || "System";
|
||||
const footerCopyright = tenantConfig?.footerCopyright || `${brandName} © 2026`;
|
||||
|
||||
const filteredFaqs = FAQS.filter(
|
||||
faq =>
|
||||
faq.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
faq.answer.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,10 +61,11 @@ export function HelpCenterFAB() {
|
||||
aria-label="Help and Resources"
|
||||
>
|
||||
<HelpCircle className="h-6 w-6" />
|
||||
<span className="absolute -inset-1 animate-ping rounded-full bg-blue-400 opacity-20" />
|
||||
{/* Pulse effect */}
|
||||
<span className="absolute -inset-1 animate-ping rounded-full bg-blue-400 opacity-20"></span>
|
||||
</motion.button>
|
||||
|
||||
{/* Drawer Overlay + Panel */}
|
||||
{/* Drawer Overlay */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
@@ -78,19 +76,20 @@ export function HelpCenterFAB() {
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="fixed inset-0 z-50 bg-background/40 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
|
||||
{/* Drawer Panel */}
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed right-0 top-0 z-50 flex h-full w-full max-w-sm flex-col bg-card/95 backdrop-blur-xl border-l border-border shadow-2xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-6 py-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground">Help & Resources</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">User Guide & Documentation</p>
|
||||
<h2 className="text-lg font-bold text-foreground">Help & Resources</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{brandName} Guide & Documentation</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
@@ -101,52 +100,46 @@ export function HelpCenterFAB() {
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
|
||||
|
||||
{/* Full User Guide Banner */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" /> Documentation
|
||||
</h3>
|
||||
<Link
|
||||
href="/help"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center justify-between gap-3 rounded-xl border border-blue-500/30 bg-blue-600/10 px-4 py-3 hover:bg-blue-600/20 transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-blue-500 dark:text-blue-400">
|
||||
Read Full User Guide
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Panduan membaca data per halaman, tab, dan kolom tabel
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-blue-500 shrink-0 group-hover:translate-x-0.5 transition-transform" />
|
||||
</Link>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-8">
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search articles or FAQs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-secondary/50 py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{/* FAQs */}
|
||||
<div className="space-y-2 pb-6">
|
||||
<div className="space-y-3 pb-8">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4" /> Frequently Asked Questions
|
||||
</h3>
|
||||
<div className="rounded-xl border border-border/50 bg-card px-4">
|
||||
{FAQS.map((faq, index) => (
|
||||
<FAQItem key={index} question={faq.question} answer={faq.answer} />
|
||||
))}
|
||||
{filteredFaqs.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-4 text-center">No matching FAQs found</p>
|
||||
) : (
|
||||
filteredFaqs.map((faq, index) => (
|
||||
<FAQItem key={index} question={faq.question} answer={faq.answer} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border/50 bg-secondary/30 p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tenantConfig?.brandName || "DPI Dashboard"} © 2026
|
||||
<br />
|
||||
{tenantConfig?.footerCopyright || "PT. Data Bisnis Solusi"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground" dangerouslySetInnerHTML={{ __html: footerCopyright }} />
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// Client-side cache to prevent duplicate fetches for the same IP
|
||||
const geoIpCache: Record<string, { isp: string; country: string; as_org: string }> = {};
|
||||
|
||||
export function IpDetails({ ip }: { ip: string }) {
|
||||
const [details, setDetails] = useState<{ isp: string; country: string; as_org: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ip) return;
|
||||
|
||||
// Check memory cache first
|
||||
if (geoIpCache[ip]) {
|
||||
setDetails(geoIpCache[ip]);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
fetch(`/api/auth/geoip?ip=${encodeURIComponent(ip)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!isMounted) return;
|
||||
const resolved = {
|
||||
isp: data.isp || "Unknown ISP",
|
||||
country: data.country || "Remote",
|
||||
as_org: data.as_org || "Public Network",
|
||||
};
|
||||
geoIpCache[ip] = resolved;
|
||||
setDetails(resolved);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("GeoIP lookup client error:", err);
|
||||
if (isMounted) {
|
||||
setDetails({
|
||||
isp: "Public IP",
|
||||
country: "Remote",
|
||||
as_org: "Public Network",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [ip]);
|
||||
|
||||
if (!details) {
|
||||
return <span className="text-[10px] text-slate-500 animate-pulse">loading...</span>;
|
||||
}
|
||||
|
||||
const isLocal = details.country === "Local";
|
||||
|
||||
return (
|
||||
<div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate" title={`${details.isp}${!isLocal ? ` • ${details.country}` : ''}`}>
|
||||
<span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}>
|
||||
{details.isp}
|
||||
</span>
|
||||
{!isLocal && (
|
||||
<span className="text-slate-400 font-normal">
|
||||
{" • "}{details.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// Client-side cache to prevent duplicate fetches for the same IP
|
||||
const geoIpCache: Record<string, { isp: string; country: string; as_org: string }> = {};
|
||||
|
||||
export function IpDetails({ ip }: { ip: string }) {
|
||||
const [details, setDetails] = useState<{ isp: string; country: string; as_org: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ip) return;
|
||||
|
||||
// Check memory cache first
|
||||
if (geoIpCache[ip]) {
|
||||
setDetails(geoIpCache[ip]);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
fetch(`/api/auth/geoip?ip=${encodeURIComponent(ip)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!isMounted) return;
|
||||
const resolved = {
|
||||
isp: data.isp || "Unknown ISP",
|
||||
country: data.country || "Remote",
|
||||
as_org: data.as_org || "Public Network",
|
||||
};
|
||||
geoIpCache[ip] = resolved;
|
||||
setDetails(resolved);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("GeoIP lookup client error:", err);
|
||||
if (isMounted) {
|
||||
setDetails({
|
||||
isp: "Public IP",
|
||||
country: "Remote",
|
||||
as_org: "Public Network",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [ip]);
|
||||
|
||||
if (!details) {
|
||||
return <span className="text-[10px] text-slate-500 animate-pulse">loading...</span>;
|
||||
}
|
||||
|
||||
const isLocal = details.country === "Local";
|
||||
|
||||
return (
|
||||
<div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate" title={`${details.isp}${!isLocal ? ` • ${details.country}` : ''}`}>
|
||||
<span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}>
|
||||
{details.isp}
|
||||
</span>
|
||||
{!isLocal && (
|
||||
<span className="text-slate-400 font-normal">
|
||||
{" • "}{details.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+82
-82
@@ -1,82 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setVisible(true);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
if (visible) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "unset";
|
||||
}, 480);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [isOpen, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isMounted || !visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className={cn(
|
||||
"relative z-50 w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl sm:my-8",
|
||||
isClosing ? 'animate-modal-out' : 'animate-modal-in',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-foreground tracking-tight">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setVisible(true);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
if (visible) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "unset";
|
||||
}, 480);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [isOpen, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isMounted || !visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className={cn(
|
||||
"relative z-[9999] w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl sm:my-8",
|
||||
isClosing ? 'animate-modal-out' : 'animate-modal-in',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-foreground tracking-tight">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function RemoteIpDetailModal({ ip, onClose }: Props) {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/60 backdrop-blur-md ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-card border border-border rounded-3xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/10 dark:shadow-blue-900/20 overflow-hidden backdrop-blur-xl ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
|
||||
@@ -4,9 +4,10 @@ import React, { useState, useEffect } from "react";
|
||||
|
||||
interface SafeImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
fallbackName?: string;
|
||||
}
|
||||
|
||||
export function SafeImage({ src, ...props }: SafeImageProps) {
|
||||
export function SafeImage({ src, fallbackName, ...props }: SafeImageProps) {
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
@@ -44,6 +45,9 @@ export function SafeImage({ src, ...props }: SafeImageProps) {
|
||||
}, [src]);
|
||||
|
||||
if (error || !imageSrc) {
|
||||
const initials = fallbackName
|
||||
? fallbackName.substring(0, 2).toUpperCase()
|
||||
: "Profile";
|
||||
return (
|
||||
<div
|
||||
className={props.className}
|
||||
@@ -55,7 +59,7 @@ export function SafeImage({ src, ...props }: SafeImageProps) {
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">
|
||||
Profile
|
||||
{initials}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
+49
-49
@@ -1,49 +1,49 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface TabProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tab({ children }: TabProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
children: React.ReactElement<TabProps>[];
|
||||
}
|
||||
|
||||
export function Tabs({ children }: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex space-x-1 border-b border-white/10 overflow-x-auto pb-1">
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (!React.isValidElement(child)) return null;
|
||||
const isActive = index === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className={`
|
||||
px-4 py-2 text-sm font-medium rounded-t-lg transition-colors whitespace-nowrap
|
||||
${isActive
|
||||
? 'bg-blue-600/20 text-blue-400 border-b-2 border-blue-500'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'}
|
||||
`}
|
||||
>
|
||||
{child.props.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (index !== activeTab) return null;
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface TabProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tab({ children }: TabProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
children: React.ReactElement<TabProps>[];
|
||||
}
|
||||
|
||||
export function Tabs({ children }: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex space-x-1 border-b border-white/10 overflow-x-auto pb-1">
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (!React.isValidElement(child)) return null;
|
||||
const isActive = index === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveTab(index)}
|
||||
className={`
|
||||
px-4 py-2 text-sm font-medium rounded-t-lg transition-colors whitespace-nowrap
|
||||
${isActive
|
||||
? 'bg-blue-600/20 text-blue-400 border-b-2 border-blue-500'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'}
|
||||
`}
|
||||
>
|
||||
{child.props.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (index !== activeTab) return null;
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,7 +99,12 @@ export function UniversalFilters({
|
||||
value={f.value || "All"}
|
||||
onChange={(val) => onChange(f.id, val)}
|
||||
options={f.options || []}
|
||||
placeholder={f.placeholder || `All ${f.label}s`}
|
||||
placeholder={
|
||||
f.placeholder ||
|
||||
(f.label.toLowerCase().endsWith('y')
|
||||
? `All ${f.label.slice(0, -1)}ies`
|
||||
: `All ${f.label}s`)
|
||||
}
|
||||
optionFormatter={f.formatter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+232
-232
@@ -1,232 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo, useState, useEffect } from "react";
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Sphere,
|
||||
Graticule,
|
||||
ZoomableGroup
|
||||
} from "react-simple-maps";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
interface WorldMapProps {
|
||||
data: {
|
||||
countryCode: string;
|
||||
value: number;
|
||||
download?: number;
|
||||
upload?: number;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
const [tooltipData, setTooltipData] = useState<any>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [logBounds, setLogBounds] = useState({ min: 0, max: 1 });
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [position, setPosition] = useState({ coordinates: [0, 30] as [number, number], zoom: 1 });
|
||||
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
const logs = data.filter(d => d.value > 0).map(d => Math.log10(d.value));
|
||||
if (logs.length > 0) {
|
||||
const minLog = Math.min(...logs);
|
||||
const maxLog = Math.max(...logs);
|
||||
setLogBounds({
|
||||
min: minLog,
|
||||
max: maxLog > minLog ? maxLog : minLog + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const { min, max } = logBounds;
|
||||
const step = (max - min) / 4;
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain([min, min + step, min + step * 2, min + step * 3, max])
|
||||
.range(isLight
|
||||
? ["#e0f2fe", "#bae6fd", "#7dd3fc", "#38bdf8", "#0284c7"] // Light Mode: Clean Sky Blues
|
||||
: ["#0f172a", "#1e3a8a", "#1d4ed8", "#2563eb", "#60a5fa"] // Dark Mode: Deep Blues
|
||||
);
|
||||
|
||||
const defaultLandColor = isLight ? "#f1f5f9" : "#111827";
|
||||
const oceanColor = isLight ? "#f8fafc" : "transparent";
|
||||
const strokeColor = isLight ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.05)";
|
||||
|
||||
function handleZoomIn() {
|
||||
if (position.zoom >= 4) return;
|
||||
setPosition((pos) => ({ ...pos, zoom: pos.zoom * 1.5 }));
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
if (position.zoom <= 1) return;
|
||||
setPosition((pos) => ({ ...pos, zoom: pos.zoom / 1.5 }));
|
||||
}
|
||||
|
||||
function handleMoveEnd(pos: { coordinates: [number, number]; zoom: number }) {
|
||||
setPosition(pos);
|
||||
}
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full aspect-[2/1] bg-card rounded-xl overflow-hidden border border-border/50 shadow-inner group"
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
}}
|
||||
onMouseLeave={() => setTooltipData(null)}
|
||||
>
|
||||
{/* Legend */}
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-background/80 p-3 rounded-lg border border-border/50 backdrop-blur-md shadow-lg">
|
||||
<div className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
Heatmap Intensity
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="text-xs text-muted-foreground">Low</div>
|
||||
<div
|
||||
className="w-24 h-2 rounded-full shadow-sm"
|
||||
style={{
|
||||
background: isLight
|
||||
? 'linear-gradient(to right, #e0f2fe, #bae6fd, #7dd3fc, #38bdf8, #0284c7)'
|
||||
: 'linear-gradient(to right, #1e3a8a, #1d4ed8, #2563eb, #60a5fa)'
|
||||
}}
|
||||
></div>
|
||||
<div className="text-xs text-muted-foreground">High</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm"
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Floating Mouse Tooltip */}
|
||||
{tooltipData && (
|
||||
<div
|
||||
style={{ top: mousePos.y + 15, left: mousePos.x + 15 }}
|
||||
className="absolute z-50 bg-background/95 backdrop-blur-xl border border-border px-4 py-3 rounded-xl shadow-2xl pointer-events-none animate-in fade-in zoom-in-95 duration-100 min-w-[200px]"
|
||||
>
|
||||
<div className="text-sm font-bold text-foreground mb-2 flex items-center gap-2 border-b border-border/50 pb-2">
|
||||
{tooltipData.label}
|
||||
</div>
|
||||
{tooltipData.value > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="font-semibold text-sky-400">{fmtBytes(tooltipData.download || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="font-semibold text-emerald-400">{fmtBytes(tooltipData.upload || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs pt-1 mt-1 border-t border-border/30">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-bold text-rose-400">{fmtBytes(tooltipData.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground italic">No traffic recorded</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 140,
|
||||
center: [0, 30]
|
||||
}}
|
||||
width={800}
|
||||
height={400}
|
||||
className="w-full h-full outline-none cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<ZoomableGroup
|
||||
zoom={position.zoom}
|
||||
center={position.coordinates}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
maxZoom={6}
|
||||
translateExtent={[[-200, -100], [1000, 600]]}
|
||||
>
|
||||
<Sphere stroke={strokeColor} strokeWidth={0.5} id="sphere" fill={oceanColor} />
|
||||
<Graticule stroke={strokeColor} strokeWidth={0.5} />
|
||||
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => {
|
||||
const d = data.find((s) => {
|
||||
if (!geo.properties.name) return false;
|
||||
const geoName = geo.properties.name.toLowerCase();
|
||||
const sLabel = s.label.toLowerCase();
|
||||
|
||||
return geoName === sLabel ||
|
||||
(sLabel === 'united states' && geoName === 'united states of america') ||
|
||||
(sLabel === 'the netherlands' && geoName === 'netherlands') ||
|
||||
(sLabel === 'south korea' && geoName === 'korea');
|
||||
});
|
||||
|
||||
const hasData = d && d.value > 0;
|
||||
const fillColor = hasData ? colorScale(Math.log10(d.value)) : defaultLandColor;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none", transition: "fill 300ms" },
|
||||
hover: {
|
||||
fill: hasData ? "#38bdf8" : (isLight ? "#e2e8f0" : "#334155"), // Sky-400 glow or fallback
|
||||
stroke: "#ffffff",
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
transition: "all 150ms ease",
|
||||
filter: hasData ? "drop-shadow(0 0 8px rgba(56,189,248,0.6))" : "none"
|
||||
},
|
||||
pressed: { outline: "none" }
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setTooltipData(d ? d : { label: geo.properties.name, value: 0 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorldMap = memo(WorldMapComponent);
|
||||
"use client";
|
||||
|
||||
import React, { memo, useState, useEffect } from "react";
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
Sphere,
|
||||
Graticule,
|
||||
ZoomableGroup
|
||||
} from "react-simple-maps";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
interface WorldMapProps {
|
||||
data: {
|
||||
countryCode: string;
|
||||
value: number;
|
||||
download?: number;
|
||||
upload?: number;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
const [tooltipData, setTooltipData] = useState<any>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [logBounds, setLogBounds] = useState({ min: 0, max: 1 });
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [position, setPosition] = useState({ coordinates: [0, 30] as [number, number], zoom: 1 });
|
||||
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
const logs = data.filter(d => d.value > 0).map(d => Math.log10(d.value));
|
||||
if (logs.length > 0) {
|
||||
const minLog = Math.min(...logs);
|
||||
const maxLog = Math.max(...logs);
|
||||
setLogBounds({
|
||||
min: minLog,
|
||||
max: maxLog > minLog ? maxLog : minLog + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const { min, max } = logBounds;
|
||||
const step = (max - min) / 4;
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain([min, min + step, min + step * 2, min + step * 3, max])
|
||||
.range(isLight
|
||||
? ["#e0f2fe", "#bae6fd", "#7dd3fc", "#38bdf8", "#0284c7"] // Light Mode: Clean Sky Blues
|
||||
: ["#0f172a", "#1e3a8a", "#1d4ed8", "#2563eb", "#60a5fa"] // Dark Mode: Deep Blues
|
||||
);
|
||||
|
||||
const defaultLandColor = isLight ? "#f1f5f9" : "#111827";
|
||||
const oceanColor = isLight ? "#f8fafc" : "transparent";
|
||||
const strokeColor = isLight ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.05)";
|
||||
|
||||
function handleZoomIn() {
|
||||
if (position.zoom >= 4) return;
|
||||
setPosition((pos) => ({ ...pos, zoom: pos.zoom * 1.5 }));
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
if (position.zoom <= 1) return;
|
||||
setPosition((pos) => ({ ...pos, zoom: pos.zoom / 1.5 }));
|
||||
}
|
||||
|
||||
function handleMoveEnd(pos: { coordinates: [number, number]; zoom: number }) {
|
||||
setPosition(pos);
|
||||
}
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full aspect-[2/1] bg-card rounded-xl overflow-hidden border border-border/50 shadow-inner group"
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
}}
|
||||
onMouseLeave={() => setTooltipData(null)}
|
||||
>
|
||||
{/* Legend */}
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-background/80 p-3 rounded-lg border border-border/50 backdrop-blur-md shadow-lg">
|
||||
<div className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
Heatmap Intensity
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="text-xs text-muted-foreground">Low</div>
|
||||
<div
|
||||
className="w-24 h-2 rounded-full shadow-sm"
|
||||
style={{
|
||||
background: isLight
|
||||
? 'linear-gradient(to right, #e0f2fe, #bae6fd, #7dd3fc, #38bdf8, #0284c7)'
|
||||
: 'linear-gradient(to right, #1e3a8a, #1d4ed8, #2563eb, #60a5fa)'
|
||||
}}
|
||||
></div>
|
||||
<div className="text-xs text-muted-foreground">High</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm"
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Floating Mouse Tooltip */}
|
||||
{tooltipData && (
|
||||
<div
|
||||
style={{ top: mousePos.y + 15, left: mousePos.x + 15 }}
|
||||
className="absolute z-50 bg-background/95 backdrop-blur-xl border border-border px-4 py-3 rounded-xl shadow-2xl pointer-events-none animate-in fade-in zoom-in-95 duration-100 min-w-[200px]"
|
||||
>
|
||||
<div className="text-sm font-bold text-foreground mb-2 flex items-center gap-2 border-b border-border/50 pb-2">
|
||||
{tooltipData.label}
|
||||
</div>
|
||||
{tooltipData.value > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="font-semibold text-sky-400">{fmtBytes(tooltipData.download || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="font-semibold text-emerald-400">{fmtBytes(tooltipData.upload || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs pt-1 mt-1 border-t border-border/30">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-bold text-rose-400">{fmtBytes(tooltipData.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground italic">No traffic recorded</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 140,
|
||||
center: [0, 30]
|
||||
}}
|
||||
width={800}
|
||||
height={400}
|
||||
className="w-full h-full outline-none cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<ZoomableGroup
|
||||
zoom={position.zoom}
|
||||
center={position.coordinates}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
maxZoom={6}
|
||||
translateExtent={[[-200, -100], [1000, 600]]}
|
||||
>
|
||||
<Sphere stroke={strokeColor} strokeWidth={0.5} id="sphere" fill={oceanColor} />
|
||||
<Graticule stroke={strokeColor} strokeWidth={0.5} />
|
||||
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => {
|
||||
const d = data.find((s) => {
|
||||
if (!geo.properties.name) return false;
|
||||
const geoName = geo.properties.name.toLowerCase();
|
||||
const sLabel = s.label.toLowerCase();
|
||||
|
||||
return geoName === sLabel ||
|
||||
(sLabel === 'united states' && geoName === 'united states of america') ||
|
||||
(sLabel === 'the netherlands' && geoName === 'netherlands') ||
|
||||
(sLabel === 'south korea' && geoName === 'korea');
|
||||
});
|
||||
|
||||
const hasData = d && d.value > 0;
|
||||
const fillColor = hasData ? colorScale(Math.log10(d.value)) : defaultLandColor;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none", transition: "fill 300ms" },
|
||||
hover: {
|
||||
fill: hasData ? "#38bdf8" : (isLight ? "#e2e8f0" : "#334155"), // Sky-400 glow or fallback
|
||||
stroke: "#ffffff",
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
transition: "all 150ms ease",
|
||||
filter: hasData ? "drop-shadow(0 0 8px rgba(56,189,248,0.6))" : "none"
|
||||
},
|
||||
pressed: { outline: "none" }
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setTooltipData(d ? d : { label: geo.properties.name, value: 0 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorldMap = memo(WorldMapComponent);
|
||||
@@ -0,0 +1,153 @@
|
||||
import { StyleSheet } from "@react-pdf/renderer";
|
||||
|
||||
export const sharedStyles = StyleSheet.create({
|
||||
page: {
|
||||
padding: 30,
|
||||
fontSize: 9,
|
||||
fontFamily: "Helvetica",
|
||||
color: "#334155",
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1.5,
|
||||
borderBottomColor: "#3b82f6",
|
||||
paddingBottom: 12,
|
||||
marginBottom: 15,
|
||||
},
|
||||
logoText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
color: "#1e3a8a",
|
||||
},
|
||||
logoSubtext: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
marginTop: 2,
|
||||
},
|
||||
titleContainer: {
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
color: "#0f172a",
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
marginTop: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
color: "#1e3a8a",
|
||||
backgroundColor: "#f8fafc",
|
||||
padding: "4 8",
|
||||
marginTop: 15,
|
||||
marginBottom: 8,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: "#3b82f6",
|
||||
},
|
||||
grid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
marginBottom: 10,
|
||||
},
|
||||
gridCol: {
|
||||
width: "25%",
|
||||
marginBottom: 8,
|
||||
},
|
||||
gridLabel: {
|
||||
fontSize: 7,
|
||||
color: "#64748b",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 2,
|
||||
},
|
||||
gridValue: {
|
||||
fontSize: 8.5,
|
||||
fontWeight: "bold",
|
||||
color: "#0f172a",
|
||||
},
|
||||
bandwidthGrid: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
},
|
||||
bandwidthCardCyan: {
|
||||
width: "48%",
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e2e8f0",
|
||||
backgroundColor: "#f0fdfa",
|
||||
},
|
||||
bandwidthCardGreen: {
|
||||
width: "48%",
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: "#e2e8f0",
|
||||
backgroundColor: "#f0fdf4",
|
||||
},
|
||||
bandwidthLabel: {
|
||||
fontSize: 7.5,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 3,
|
||||
},
|
||||
bandwidthValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
marginBottom: 10,
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
backgroundColor: "#f1f5f9",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#cbd5e1",
|
||||
padding: "4 6",
|
||||
},
|
||||
tableHeaderCell: {
|
||||
fontSize: 7.5,
|
||||
fontWeight: "bold",
|
||||
color: "#475569",
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: "#f1f5f9",
|
||||
padding: "4 6",
|
||||
},
|
||||
tableCell: {
|
||||
fontSize: 7.5,
|
||||
color: "#334155",
|
||||
},
|
||||
alertBox: {
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
backgroundColor: "#fef2f2",
|
||||
borderWidth: 1,
|
||||
borderColor: "#fecaca",
|
||||
marginBottom: 10,
|
||||
},
|
||||
alertTitle: {
|
||||
fontSize: 8.5,
|
||||
fontWeight: "bold",
|
||||
color: "#991b1b",
|
||||
marginBottom: 2,
|
||||
},
|
||||
alertText: {
|
||||
fontSize: 7.5,
|
||||
color: "#7f1d1d",
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 8,
|
||||
color: "#64748b",
|
||||
fontStyle: "italic",
|
||||
padding: 6,
|
||||
},
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
type TimeRange = '5m' | '30m' | '1h' | '1d' | '7d';
|
||||
type TimeRange = '5m' | '30m' | '1h' | '1d' | '7d' | '30d';
|
||||
|
||||
interface TimeFilterContextType {
|
||||
timeRange: TimeRange;
|
||||
|
||||
+179
-193
@@ -1,140 +1,203 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import jwt from "jsonwebtoken";
|
||||
import mongoose from "mongoose";
|
||||
import {
|
||||
getApiKey,
|
||||
getSiteUuid,
|
||||
getHeaders,
|
||||
API_URL,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent
|
||||
} from "./agentsCore";
|
||||
import type { Agent } from "./agentsCore";
|
||||
|
||||
const BASE_URL = process.env.BACKONE_BASE_URL || process.env.NETIFY_BASE_URL;
|
||||
const API_URL = BASE_URL ? `${BASE_URL}/assets/agents` : "https://manager.netify.ai/api/v1/assets/agents";
|
||||
import { getAuthUser } from "./authHelper";
|
||||
|
||||
const getApiKey = () => process.env.BACKONE_API_KEY || process.env.NETIFY_API_KEY;
|
||||
const getJwtToken = () => process.env.BACKONE_JWT_TOKEN || process.env.NETIFY_JWT_TOKEN;
|
||||
const getSiteUuid = () => process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID;
|
||||
|
||||
// Helper function to get headers
|
||||
const getHeaders = () => {
|
||||
const API_KEY = getApiKey();
|
||||
const JWT_TOKEN = getJwtToken();
|
||||
const SITE_UUID = getSiteUuid();
|
||||
|
||||
if (!API_KEY && !JWT_TOKEN) {
|
||||
throw new Error("BACKONE_API_KEY or BACKONE_JWT_TOKEN is not set in environment variables");
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// Prioritize long-term API Key as recommended by the DPI API support team
|
||||
if (API_KEY) {
|
||||
headers["x-api-key"] = API_KEY;
|
||||
} else if (JWT_TOKEN) {
|
||||
headers["x-api-key"] = JWT_TOKEN;
|
||||
}
|
||||
|
||||
if (SITE_UUID) {
|
||||
headers["x-net-site"] = SITE_UUID;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
|
||||
export interface Agent {
|
||||
id: number;
|
||||
uuid: string;
|
||||
serial: string;
|
||||
site_uuid: string;
|
||||
organization_uuid: string;
|
||||
provisioned: boolean;
|
||||
activated: boolean;
|
||||
label?: string;
|
||||
created_at: { human: string; date: string; unix_time: number };
|
||||
updated_at: { human: string; date: string; unix_time: number };
|
||||
last_seen_at: { human: string; date: string; unix_time: number };
|
||||
}
|
||||
// Re-export mutations for backwards compatibility
|
||||
export { createAgent, updateAgent, deleteAgent };
|
||||
|
||||
export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
|
||||
try {
|
||||
const API_KEY = getApiKey();
|
||||
let SITE_UUID = clientSiteUuid || getSiteUuid();
|
||||
|
||||
// Securely retrieve the user's actual role and site_uuid on the server side
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('token')?.value;
|
||||
if (token) {
|
||||
const decoded: any = jwt.verify(token, process.env.JWT_SECRET || 'super-secret-backone-key');
|
||||
if (decoded && decoded.role === 'TENANT_ADMIN') {
|
||||
// Force user's site_uuid to prevent TENANT_ADMIN from requesting other sites
|
||||
SITE_UUID = decoded.site_uuid;
|
||||
}
|
||||
}
|
||||
} catch (cookieErr) {
|
||||
console.warn("Failed to retrieve or verify cookie token inside getAgents:", cookieErr);
|
||||
const user = await getAuthUser();
|
||||
if (!user) {
|
||||
throw new Error("Unauthorized: Please log in first.");
|
||||
}
|
||||
|
||||
// All agent data comes from MongoDB (single source of truth per Rule 13)
|
||||
const API_KEY = getApiKey();
|
||||
|
||||
// Resolve the SITE_UUID strictly based on user role and permissions
|
||||
let SITE_UUID = clientSiteUuid || getSiteUuid();
|
||||
|
||||
if (user.role === 'TENANT_ADMIN') {
|
||||
SITE_UUID = user.site_uuid;
|
||||
} else if (user.role === 'AGENT_VIEWER') {
|
||||
SITE_UUID = user.site_uuid;
|
||||
}
|
||||
|
||||
// All agent data comes from MongoDB agent_registry (single source of truth)
|
||||
// agent_registry is populated by the proxy collector every cycle from /data/agents
|
||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||
const mongoose = require('mongoose');
|
||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/backone_dpi';
|
||||
|
||||
// Connect to MongoDB if not already connected
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(MONGODB_URI);
|
||||
console.log("[getAgents Action] MONGODB_URI:", MONGODB_URI);
|
||||
console.log("[getAgents Action] Mongoose readyState:", mongoose.connection.readyState);
|
||||
try {
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
console.log("[getAgents Action] Connecting to MongoDB...");
|
||||
await mongoose.connect(MONGODB_URI, { serverSelectionTimeoutMS: 5000 });
|
||||
console.log("[getAgents Action] ✓ Mongoose connected successfully!");
|
||||
}
|
||||
} catch (connErr: any) {
|
||||
console.error("[getAgents Action] ✗ Mongoose connection failed:", connErr.message);
|
||||
throw connErr;
|
||||
}
|
||||
const db = mongoose.connection.db;
|
||||
if (!db) {
|
||||
throw new Error("Database connection not ready");
|
||||
|
||||
// Build site filter
|
||||
// CATATAN: Untuk company roles, jangan terapkan site_uuid filter global
|
||||
// karena agent yang di-assign ke company user bisa berasal dari site mana saja.
|
||||
// Filter keamanan untuk company roles HANYA menggunakan uuid: { $in: freshAgentUuids }
|
||||
const companyRoles = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'];
|
||||
const isCompanyRole = companyRoles.includes(user.role);
|
||||
|
||||
const filter: any = {};
|
||||
// Hanya apply site_uuid filter untuk NON-company roles
|
||||
if (SITE_UUID && !isCompanyRole) filter.site_uuid = SITE_UUID;
|
||||
|
||||
// 🔒 Security: COMPANY_ADMIN/OPERATOR/VIEWER hanya boleh melihat agent yang di-assign
|
||||
// freshAgentUuids dideklarasikan di outer scope agar bisa dipakai di fallback summaries
|
||||
let freshAgentUuids: string[] = [];
|
||||
if (isCompanyRole) {
|
||||
// Selalu baca agent_uuids dari database — token bisa stale jika assignment berubah setelah login
|
||||
freshAgentUuids = user.agent_uuids || [];
|
||||
try {
|
||||
const UserModel = mongoose.models.User || mongoose.model('User', new mongoose.Schema({ agent_uuids: [String] }, { strict: false }));
|
||||
const dbUser = await UserModel.findById(user.id).select('agent_uuids').lean() as any;
|
||||
if (dbUser?.agent_uuids && dbUser.agent_uuids.length > 0) {
|
||||
freshAgentUuids = dbUser.agent_uuids;
|
||||
}
|
||||
} catch (dbErr: any) {
|
||||
console.warn('[getAgents] Could not fetch fresh agent_uuids from DB, using token:', dbErr.message);
|
||||
}
|
||||
|
||||
if (freshAgentUuids.length > 0) {
|
||||
filter.uuid = { $in: freshAgentUuids };
|
||||
} else {
|
||||
// Tidak ada agent yang di-assign → kembalikan list kosong (secure by default)
|
||||
console.log(`[getAgents] ${user.role} ${user.username} has no assigned agents — returning []`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const filter: any = { agent_uuid: { $ne: null } }; // exclude site-level aggregate rows
|
||||
if (SITE_UUID) {
|
||||
filter.site_uuid = SITE_UUID;
|
||||
// 1. Get agents from agent_registry (filtered by role)
|
||||
const registryAgents = await db.collection('agent_registry')
|
||||
.find(filter)
|
||||
.sort({ uuid: 1 })
|
||||
.toArray();
|
||||
|
||||
// 2. If agent_registry is empty, fall back to summaries
|
||||
let agentSource = registryAgents;
|
||||
if (registryAgents.length === 0) {
|
||||
const summaryFilter: any = { agent_uuid: { $nin: [null, ''], $exists: true } };
|
||||
// Hanya apply site_uuid filter untuk non-company roles
|
||||
if (SITE_UUID && !isCompanyRole) summaryFilter.site_uuid = SITE_UUID;
|
||||
// Apply company role filter on summaries fallback using fresh agent_uuids from DB
|
||||
if (isCompanyRole && freshAgentUuids.length > 0) {
|
||||
summaryFilter.agent_uuid = { $in: freshAgentUuids };
|
||||
} else if (isCompanyRole && freshAgentUuids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const agentSummaries = await db.collection('summaries')
|
||||
.aggregate([
|
||||
{ $match: summaryFilter },
|
||||
{ $sort: { timestamp: -1 } },
|
||||
{ $group: { _id: '$agent_uuid', lastSeen: { $first: '$timestamp' }, label: { $first: '$agent_label' } } },
|
||||
]).toArray();
|
||||
// Map summaries to registry-like format
|
||||
agentSource = agentSummaries.map((s: any) => ({
|
||||
uuid: s._id, serial: s._id, label: s.label, site_uuid: SITE_UUID,
|
||||
provisioned: true, activated: true, last_seen_at: s.lastSeen ? { date: s.lastSeen.toISOString() } : null,
|
||||
}));
|
||||
}
|
||||
|
||||
// Get all agents from MongoDB summaries collection, deduplicated by agent_uuid
|
||||
const agentSummaries = await db.collection('summaries')
|
||||
.aggregate([
|
||||
{ $match: filter },
|
||||
{ $sort: { timestamp: -1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$agent_uuid',
|
||||
lastSeen: { $first: '$timestamp' },
|
||||
label: { $first: '$agent_label' },
|
||||
site_uuid: { $first: '$site_uuid' }, // capture the site this agent belongs to
|
||||
}
|
||||
},
|
||||
// Final guard: only keep agents whose primary site matches the queried site
|
||||
...(SITE_UUID ? [{ $match: { site_uuid: SITE_UUID } }] : []),
|
||||
]).toArray();
|
||||
|
||||
// Check for any flows recorded in the last 12 hours to determine online status
|
||||
const twelveHoursAgo = new Date(Date.now() - 12 * 3600000);
|
||||
const activeAgents = await db.collection('flows').distinct('agent_uuid', {
|
||||
timestamp: { $gte: twelveHoursAgo }
|
||||
});
|
||||
const activeAgentsSet = new Set(activeAgents);
|
||||
|
||||
const agents: Agent[] = agentSummaries.map((item: any, idx: number) => {
|
||||
const lastSeenDate = item.lastSeen ? new Date(item.lastSeen) : null;
|
||||
const isOnline = activeAgentsSet.has(item._id);
|
||||
const statusHuman = isOnline
|
||||
? 'Online'
|
||||
: lastSeenDate
|
||||
? `Last Seen ${lastSeenDate.toLocaleString('id-ID', { timeZone: 'Asia/Jakarta', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })} WIB`
|
||||
: 'Offline';
|
||||
|
||||
return {
|
||||
|
||||
// 3. Optimize status check and last seen per agent using indexed queries
|
||||
const twentyFourHoursAgo = new Date(Date.now() - 24 * 3600000);
|
||||
const agents: Agent[] = [];
|
||||
|
||||
for (let idx = 0; idx < agentSource.length; idx++) {
|
||||
const item = agentSource[idx];
|
||||
const uuid = item.uuid || item.serial;
|
||||
if (!uuid) continue;
|
||||
|
||||
// Check if there was any flow in the last 24 hours to determine online status
|
||||
// Using index: { agent_uuid: 1, timestamp: -1 }
|
||||
const activeFlow = await db.collection('flows')
|
||||
.findOne({ agent_uuid: uuid, timestamp: { $gte: twentyFourHoursAgo } }, { projection: { _id: 1 } });
|
||||
const isOnline = !!activeFlow;
|
||||
|
||||
// Check latest timestamp from flows
|
||||
// Using index: { agent_uuid: 1, timestamp: -1 }
|
||||
const latestFlow = await db.collection('flows')
|
||||
.findOne({ agent_uuid: uuid }, { projection: { timestamp: 1 }, sort: { timestamp: -1 } });
|
||||
|
||||
// Check latest timestamp from summaries
|
||||
// Using index: { agent_uuid: 1, timestamp: -1 }
|
||||
const latestSummary = await db.collection('summaries')
|
||||
.findOne({ agent_uuid: uuid }, { projection: { timestamp: 1 }, sort: { timestamp: -1 } });
|
||||
|
||||
const registryLastSeenRaw = item.last_seen_at;
|
||||
let lastSeenDate: Date | null = null;
|
||||
if (registryLastSeenRaw?.date) {
|
||||
lastSeenDate = new Date(registryLastSeenRaw.date);
|
||||
} else if (registryLastSeenRaw instanceof Date) {
|
||||
lastSeenDate = registryLastSeenRaw;
|
||||
} else if (typeof registryLastSeenRaw === 'string') {
|
||||
lastSeenDate = new Date(registryLastSeenRaw);
|
||||
}
|
||||
|
||||
if (latestFlow && latestFlow.timestamp) {
|
||||
const flowDate = new Date(latestFlow.timestamp);
|
||||
if (!lastSeenDate || flowDate > lastSeenDate) {
|
||||
lastSeenDate = flowDate;
|
||||
}
|
||||
}
|
||||
if (latestSummary && latestSummary.timestamp) {
|
||||
const summaryDate = new Date(latestSummary.timestamp);
|
||||
if (!lastSeenDate || summaryDate > lastSeenDate) {
|
||||
lastSeenDate = summaryDate;
|
||||
}
|
||||
}
|
||||
|
||||
let statusHuman = 'No Flows Detected';
|
||||
if (isOnline) {
|
||||
if (lastSeenDate) {
|
||||
const now = new Date();
|
||||
const isToday = lastSeenDate.getDate() === now.getDate() &&
|
||||
lastSeenDate.getMonth() === now.getMonth() &&
|
||||
lastSeenDate.getFullYear() === now.getFullYear();
|
||||
const timeStr = lastSeenDate.toLocaleTimeString('id-ID', { timeZone: 'Asia/Jakarta', hour: '2-digit', minute: '2-digit' }) + ' WIB';
|
||||
const dateStr = lastSeenDate.toLocaleDateString('id-ID', { timeZone: 'Asia/Jakarta', day: '2-digit', month: '2-digit' });
|
||||
statusHuman = isToday ? `Last seen: ${timeStr}` : `Last seen: ${dateStr} ${timeStr}`;
|
||||
} else {
|
||||
statusHuman = 'Last seen: Just now';
|
||||
}
|
||||
}
|
||||
|
||||
agents.push({
|
||||
id: idx + 1,
|
||||
uuid: item._id,
|
||||
serial: item._id,
|
||||
site_uuid: SITE_UUID || '',
|
||||
uuid: uuid,
|
||||
serial: item.serial || uuid,
|
||||
site_uuid: item.site_uuid || SITE_UUID || '',
|
||||
organization_uuid: '',
|
||||
provisioned: true,
|
||||
activated: isOnline,
|
||||
label: item.label || item._id,
|
||||
provisioned: item.provisioned ?? true,
|
||||
activated: true, // All retrieved agents are active/activated on the Netify platform
|
||||
label: item.label || uuid,
|
||||
created_at: { human: 'N/A', date: '', unix_time: 0 },
|
||||
updated_at: { human: 'N/A', date: '', unix_time: 0 },
|
||||
last_seen_at: {
|
||||
@@ -142,8 +205,8 @@ export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
|
||||
date: lastSeenDate?.toISOString() || '',
|
||||
unix_time: lastSeenDate ? lastSeenDate.getTime() / 1000 : 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
@@ -197,81 +260,4 @@ export async function getAgent(agentId: string): Promise<Agent | null> {
|
||||
console.error(`Error fetching agent ${agentId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAgent(agentId: string, label: string) {
|
||||
try {
|
||||
const API_KEY = getApiKey();
|
||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan membuat Agents." };
|
||||
}
|
||||
|
||||
const url = agentId
|
||||
? `${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`
|
||||
: `${API_URL}?label=${encodeURIComponent(label)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.status_code !== 0) {
|
||||
return { success: false, message: result.status_message || response.statusText };
|
||||
}
|
||||
|
||||
revalidatePath('/agents');
|
||||
return { success: true, data: result.data };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAgent(agentId: string, label: string) {
|
||||
try {
|
||||
const API_KEY = getApiKey();
|
||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan mengubah Agents." };
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`, {
|
||||
method: "PATCH",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.status_code !== 0) {
|
||||
return { success: false, message: result.status_message || response.statusText };
|
||||
}
|
||||
|
||||
revalidatePath('/agents');
|
||||
return { success: true, data: result.data };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAgent(agentId: string) {
|
||||
try {
|
||||
const API_KEY = getApiKey();
|
||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan menghapus Agents." };
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}`, {
|
||||
method: "DELETE",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.status_code !== 0) {
|
||||
return { success: false, message: result.status_message || response.statusText };
|
||||
}
|
||||
|
||||
revalidatePath('/agents');
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
export const BASE_URL = process.env.BACKONE_BASE_URL || process.env.NETIFY_BASE_URL;
|
||||
export const API_URL = BASE_URL ? `${BASE_URL}/assets/agents` : "https://manager.netify.ai/api/v1/assets/agents";
|
||||
|
||||
export const getApiKey = () => process.env.BACKONE_API_KEY || process.env.NETIFY_API_KEY;
|
||||
export const getJwtToken = () => process.env.BACKONE_JWT_TOKEN || process.env.NETIFY_JWT_TOKEN;
|
||||
export const getSiteUuid = () => process.env.BACKONE_SITE_UUID || process.env.NETIFY_SITE_UUID;
|
||||
|
||||
export interface Agent {
|
||||
id: number;
|
||||
uuid: string;
|
||||
serial: string;
|
||||
site_uuid: string;
|
||||
organization_uuid: string;
|
||||
provisioned: boolean;
|
||||
activated: boolean;
|
||||
label?: string;
|
||||
created_at: { human: string; date: string; unix_time: number };
|
||||
updated_at: { human: string; date: string; unix_time: number };
|
||||
last_seen_at: { human: string; date: string; unix_time: number };
|
||||
}
|
||||
|
||||
export const getHeaders = () => {
|
||||
const API_KEY = getApiKey();
|
||||
const JWT_TOKEN = getJwtToken();
|
||||
const SITE_UUID = getSiteUuid();
|
||||
|
||||
if (!API_KEY && !JWT_TOKEN) {
|
||||
throw new Error("BACKONE_API_KEY or BACKONE_JWT_TOKEN is not set in environment variables");
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (API_KEY) {
|
||||
headers["x-api-key"] = API_KEY;
|
||||
} else if (JWT_TOKEN) {
|
||||
headers["x-api-key"] = JWT_TOKEN;
|
||||
}
|
||||
|
||||
if (SITE_UUID) {
|
||||
headers["x-net-site"] = SITE_UUID;
|
||||
}
|
||||
|
||||
return headers;
|
||||
};
|
||||
|
||||
export async function createAgent(agentId: string, label: string) {
|
||||
try {
|
||||
const API_KEY = getApiKey();
|
||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan membuat Agents." };
|
||||
}
|
||||
|
||||
const url = agentId
|
||||
? `${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`
|
||||
: `${API_URL}?label=${encodeURIComponent(label)}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.status_code !== 0) {
|
||||
return { success: false, message: result.status_message || response.statusText };
|
||||
}
|
||||
|
||||
revalidatePath('/agents');
|
||||
return { success: true, data: result.data };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateAgent(agentId: string, label: string) {
|
||||
try {
|
||||
const API_KEY = getApiKey();
|
||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan mengubah Agents." };
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}?label=${encodeURIComponent(label)}`, {
|
||||
method: "PATCH",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.status_code !== 0) {
|
||||
return { success: false, message: result.status_message || response.statusText };
|
||||
}
|
||||
|
||||
revalidatePath('/agents');
|
||||
return { success: true, data: result.data };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAgent(agentId: string) {
|
||||
try {
|
||||
const API_KEY = getApiKey();
|
||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||
return { success: false, message: "Aksi ditolak: Site-scoped API Key tidak diperbolehkan menghapus Agents." };
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/${encodeURIComponent(agentId)}`, {
|
||||
method: "DELETE",
|
||||
headers: getHeaders(),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || result.status_code !== 0) {
|
||||
return { success: false, message: result.status_message || response.statusText };
|
||||
}
|
||||
|
||||
revalidatePath('/agents');
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
Loaded 100 of 385 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user