v1.1: Add CIDR subnet filtering and Agent filter in Devices page

This commit is contained in:
ypratama committed 2026-08-27 12:02:01 +07:00
commit 966058e2fe
422 files changed
+54656

No files matched your search

+280
View File
@@ -0,0 +1,280 @@
"use client";
import React, { useState, useEffect } from "react";
import { Plus, Edit2, Trash2, Users, Loader2, Save, X } from "lucide-react";
import { getAgents } from "@/lib/actions/agents";
export default function AgentGroupingPage() {
const [groups, setGroups] = useState<any[]>([]);
const [agents, setAgents] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingGroup, setEditingGroup] = useState<any>(null);
const [formData, setFormData] = useState({
name: "",
description: "",
agent_uuids: [] as string[]
});
const fetchData = async () => {
setIsLoading(true);
try {
const [groupsRes, agentsData] = await Promise.all([
fetch("/api/v1/groups"),
getAgents()
]);
const groupsData = await groupsRes.json();
setGroups(Array.isArray(groupsData) ? groupsData : []);
setAgents(Array.isArray(agentsData) ? agentsData : []);
} catch (err) {
console.error("Failed to fetch data:", err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const openCreateModal = () => {
setEditingGroup(null);
setFormData({ name: "", description: "", agent_uuids: [] });
setIsModalOpen(true);
};
const openEditModal = (group: any) => {
setEditingGroup(group);
setFormData({
name: group.name,
description: group.description || "",
agent_uuids: group.agent_uuids || []
});
setIsModalOpen(true);
};
const closeModal = () => setIsModalOpen(false);
const toggleAgent = (uuid: string) => {
setFormData(prev => ({
...prev,
agent_uuids: prev.agent_uuids.includes(uuid)
? prev.agent_uuids.filter(id => id !== uuid)
: [...prev.agent_uuids, uuid]
}));
};
const handleSave = async () => {
try {
const method = editingGroup ? "PUT" : "POST";
const url = editingGroup ? `/api/v1/groups/${editingGroup.id}` : `/api/v1/groups`;
await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData)
});
closeModal();
fetchData();
} catch (err) {
console.error("Failed to save group:", err);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Are you sure you want to delete this group?")) return;
try {
await fetch(`/api/v1/groups/${id}`, { method: "DELETE" });
fetchData();
} catch (err) {
console.error("Failed to delete group:", err);
}
};
return (
<div className="flex-1 space-y-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-blue-400 to-indigo-500 bg-clip-text text-transparent flex items-center gap-2">
<Users className="w-8 h-8 text-blue-400" />
Agent Grouping
</h1>
<p className="text-slate-400 mt-2">
Create and manage logical groups for your DPI Agents.
</p>
</div>
<button
onClick={openCreateModal}
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors font-medium shadow-lg shadow-blue-900/20"
>
<Plus className="w-4 h-4" />
Create New Group
</button>
</div>
{isLoading ? (
<div className="flex justify-center items-center py-20">
<Loader2 className="w-8 h-8 animate-spin text-blue-500" />
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{groups.length === 0 ? (
<div className="col-span-full bg-slate-900/50 rounded-xl border border-white/[0.05] p-12 text-center text-slate-400">
<Users className="w-12 h-12 mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium text-slate-300">No Groups Found</h3>
<p className="mt-1">You haven't created any agent groups yet.</p>
</div>
) : (
groups.map(group => (
<div key={group.id} className="bg-slate-900/60 rounded-xl border border-white/10 p-5 hover:border-blue-500/30 transition-all flex flex-col h-full group">
<div className="flex justify-between items-start mb-3">
<h3 className="text-lg font-semibold text-slate-200">{group.name}</h3>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => openEditModal(group)} className="p-1.5 text-slate-400 hover:text-blue-400 hover:bg-blue-500/10 rounded-md">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(group.id)} className="p-1.5 text-slate-400 hover:text-red-400 hover:bg-red-500/10 rounded-md">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<p className="text-sm text-slate-400 mb-4 flex-1">
{group.description || "No description provided."}
</p>
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<span className="text-xs font-medium bg-slate-800 text-slate-300 px-2 py-1 rounded-md">
{group.agent_uuids?.length || 0} Agents Assigned
</span>
</div>
</div>
))
)}
</div>
)}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-lg shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<div className="flex justify-between items-center p-5 border-b border-slate-800">
<h2 className="text-xl font-bold text-slate-200">
{editingGroup ? "Edit Agent Group" : "Create Agent Group"}
</h2>
<button onClick={closeModal} className="text-slate-400 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-5 overflow-y-auto space-y-5">
<div>
<label className="block text-sm font-medium text-slate-400 mb-1">Group Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
placeholder="e.g., East Coast Agents"
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
placeholder="Optional description..."
rows={2}
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-slate-200 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-2">Assign Agents</label>
<div className="relative">
<div
onClick={() => {
const el = document.getElementById('agent-dropdown');
if (el) el.classList.toggle('hidden');
}}
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-slate-200 cursor-pointer flex justify-between items-center"
>
<span className="truncate">
{formData.agent_uuids.length === 0
? "Select agents..."
: `${formData.agent_uuids.length} agents selected`}
</span>
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
<div id="agent-dropdown" className="hidden absolute z-10 w-full mt-1 bg-slate-950 border border-slate-800 rounded-lg shadow-lg max-h-48 overflow-y-auto">
{agents.length === 0 ? (
<div className="p-3 text-center text-sm text-slate-500">No agents available.</div>
) : (
agents.map(agent => {
const isSelected = formData.agent_uuids.includes(agent.uuid || agent.agent_uuid);
const id = agent.uuid || agent.agent_uuid;
return (
<div
key={id}
onClick={() => toggleAgent(id)}
className={`p-2 flex items-center gap-3 cursor-pointer transition-colors ${isSelected ? 'bg-blue-500/10' : 'hover:bg-slate-900'}`}
>
<div className={`w-4 h-4 rounded-sm border flex items-center justify-center shrink-0 ${isSelected ? 'border-blue-500 bg-blue-500' : 'border-slate-600'}`}>
{isSelected && <div className="w-2 h-2 bg-white rounded-sm" />}
</div>
<div className="min-w-0">
<p className={`text-sm font-medium truncate ${isSelected ? 'text-blue-400' : 'text-slate-300'}`}>
{agent.label || agent.name || id}
</p>
<p className="text-xs text-slate-500 font-mono truncate">{id}</p>
</div>
</div>
)
})
)}
</div>
</div>
{/* Selected Agents Badges */}
<div className="mt-3 flex flex-wrap gap-2">
{formData.agent_uuids.map(uuid => {
const agent = agents.find(a => (a.uuid || a.agent_uuid) === uuid);
const label = agent ? (agent.label || agent.name || uuid) : uuid;
return (
<div key={uuid} className="bg-slate-800 text-slate-200 text-xs px-2 py-1 rounded-md flex items-center gap-1">
<span className="truncate max-w-[120px]">{label}</span>
<button onClick={() => toggleAgent(uuid)} className="text-slate-400 hover:text-white">
<X className="w-3 h-3" />
</button>
</div>
);
})}
</div>
</div>
</div>
<div className="p-5 border-t border-slate-800 bg-slate-900/50 flex justify-end gap-3">
<button
onClick={closeModal}
className="px-4 py-2 rounded-lg text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-800 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!formData.name}
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white flex items-center gap-2 transition-colors"
>
<Save className="w-4 h-4" />
{editingGroup ? "Update Group" : "Create Group"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,124 @@
"use client";
import { AgentModals } from "@/components/admin/AgentModals";
import ExternalAccountModal from "@/components/admin/ExternalAccountModal";
import AgentLocationModal from "@/components/admin/AgentLocationModal";
import { Agent } from "@/lib/actions/agentsCore";
import { type ManagedUser } from "@/lib/admin-api";
interface AgentsPageModalsProps {
isCreateOpen: boolean;
setIsCreateOpen: (open: boolean) => void;
isDeleteOpen: boolean;
setIsDeleteOpen: (open: boolean) => void;
selectedAgent: Agent | null;
loadAgents: () => Promise<void>;
detailAgent: Agent | null;
setDetailAgent: (agent: Agent | null) => void;
accountModalOpen: boolean;
setAccountModalOpen: (open: boolean) => void;
accountModalAgent: { uuid: string; label: string } | null;
setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void;
managedUsers: ManagedUser[];
externalModalOpen: boolean;
setExternalModalOpen: (open: boolean) => void;
externalModalUser: ManagedUser | null;
setExternalModalUser: (user: ManagedUser | null) => void;
targetSiteUuid: string | null;
setTargetSiteUuid: (uuid: string | null) => void;
targetCreatedBy: string | null;
setTargetCreatedBy: (createdBy: string | null) => void;
isLocModalOpen: boolean;
setIsLocModalOpen: (open: boolean) => void;
locModalAgentUuid: string;
setLocModalAgentUuid: (uuid: string) => void;
locModalAgentLabel: string;
setLocModalAgentLabel: (label: string) => void;
locModalCurrentLoc: any | null;
setLocModalCurrentLoc: (loc: any | null) => void;
loadLocations: () => Promise<void>;
}
export default function AgentsPageModals({
isCreateOpen,
setIsCreateOpen,
isDeleteOpen,
setIsDeleteOpen,
selectedAgent,
loadAgents,
detailAgent,
setDetailAgent,
accountModalOpen,
setAccountModalOpen,
accountModalAgent,
setAccountModalAgent,
managedUsers,
externalModalOpen,
setExternalModalOpen,
externalModalUser,
setExternalModalUser,
targetSiteUuid,
setTargetSiteUuid,
targetCreatedBy,
setTargetCreatedBy,
isLocModalOpen,
setIsLocModalOpen,
locModalAgentUuid,
setLocModalAgentUuid,
locModalAgentLabel,
setLocModalAgentLabel,
locModalCurrentLoc,
setLocModalCurrentLoc,
loadLocations,
}: AgentsPageModalsProps) {
return (
<>
<AgentModals
isCreateOpen={isCreateOpen}
setIsCreateOpen={setIsCreateOpen}
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
selectedAgent={selectedAgent}
onSuccess={loadAgents}
detailAgent={detailAgent}
setDetailAgent={setDetailAgent}
accountModalOpen={accountModalOpen}
setAccountModalOpen={setAccountModalOpen}
accountModalAgent={accountModalAgent}
setAccountModalAgent={setAccountModalAgent}
managedUsers={managedUsers}
/>
<ExternalAccountModal
isOpen={externalModalOpen}
onClose={() => {
setExternalModalOpen(false);
setExternalModalUser(null);
setTargetSiteUuid(null);
setTargetCreatedBy(null);
}}
onSuccess={loadAgents}
user={externalModalUser}
targetSiteUuid={targetSiteUuid}
targetCreatedBy={targetCreatedBy}
modalContext="agents"
/>
<AgentLocationModal
isOpen={isLocModalOpen}
onClose={() => {
setIsLocModalOpen(false);
setLocModalAgentUuid("");
setLocModalAgentLabel("");
setLocModalCurrentLoc(null);
}}
onSuccess={() => {
loadLocations();
}}
agentUuid={locModalAgentUuid}
agentLabel={locModalAgentLabel}
currentLocation={locModalCurrentLoc}
/>
</>
);
}
@@ -0,0 +1,60 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable, Column } from "@/components/ui/DataTable";
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 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 ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<DataTable
data={agents}
columns={columns}
searchPlaceholder="Search agents..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.uuid || "").toLowerCase().includes(q) ||
(row.serial || "").toLowerCase().includes(q) ||
(row.label || "").toLowerCase().includes(q)
);
}}
/>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,242 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable, Column } from "@/components/ui/DataTable";
import { Plus, Loader2 } from "lucide-react";
import { type ManagedUser } from "@/lib/admin-api";
interface ExternalAccountsSectionProps {
role: string | null;
isLoading: boolean;
externalUsers: ManagedUser[];
superadminUsers: ManagedUser[];
siabUsers: ManagedUser[];
nexusUsers: ManagedUser[];
externalColumns: Column<ManagedUser>[];
setExternalModalUser: (user: ManagedUser | null) => void;
setTargetSiteUuid: (uuid: string | null) => void;
setTargetCreatedBy: (createdBy: string | null) => void;
setExternalModalOpen: (open: boolean) => void;
}
export default function ExternalAccountsSection({
role,
isLoading,
externalUsers,
superadminUsers,
siabUsers,
nexusUsers,
externalColumns,
setExternalModalUser,
setTargetSiteUuid,
setTargetCreatedBy,
setExternalModalOpen,
}: ExternalAccountsSectionProps) {
if (role === 'SUPER_ADMIN') {
return (
<div className="space-y-6">
{/* Table 1: Superadmin (BackOne) 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 flex items-center gap-2">
<span>Superadmin (BackOne) External Accounts ({superadminUsers.length})</span>
</CardTitle>
<button
onClick={() => {
setExternalModalUser(null);
setTargetSiteUuid(null);
setTargetCreatedBy('admin');
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 Technical Account
</button>
</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={superadminUsers}
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>
{/* Table 2: SIAB 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">SIAB Tenant External Accounts ({siabUsers.length})</CardTitle>
<button
onClick={() => {
setExternalModalUser(null);
setTargetSiteUuid('6681452d_9cae_4ff4_8ae8_0d504774265e');
setTargetCreatedBy('siab');
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 Technical Account
</button>
</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={siabUsers}
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>
{/* 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">Office Tenant External Accounts ({nexusUsers.length})</CardTitle>
<button
onClick={() => {
setExternalModalUser(null);
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 Technical Account
</button>
</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={nexusUsers}
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>
</div>
);
}
if (role === 'TENANT_ADMIN') {
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">External Accounts Inventory ({externalUsers.length})</CardTitle>
<button
onClick={() => {
setExternalModalUser(null);
setTargetSiteUuid(null);
setTargetCreatedBy(null);
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 Technical Account
</button>
</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>
);
}
// 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;
}
@@ -0,0 +1,50 @@
"use client";
import { Server, CheckCircle2, XCircle, Activity } from "lucide-react";
interface UptimeStatsCardsProps {
totalAgents: number;
onlineAgents: number;
offlineAgents: number;
avgUptime: number;
}
export default function UptimeStatsCards({
totalAgents,
onlineAgents,
offlineAgents,
avgUptime,
}: UptimeStatsCardsProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-xs font-semibold">
<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">
<Server className="w-12 h-12 text-blue-400" />
</div>
<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 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 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 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>
);
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { Column } from "@/components/ui/DataTable";
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, Network } from "lucide-react";
interface GetColumnsProps {
role: string | null;
uptimeMap: Record<string, number>;
storageMap?: Record<string, number>;
managedUsers: ManagedUser[];
viewAsLoading: string | null;
setDetailAgent: (agent: Agent | null) => void;
setAccountModalOpen: (open: boolean) => void;
setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void;
handleViewAs: (uuid: string, label: string) => void;
openDelete: (agent: Agent) => void;
handleEditLocation?: (agentUuid: string, agentLabel: string) => void;
handleEditSubnet?: (agentUuid: string, agentLabel: string) => void;
/** Set of agent UUIDs that already have a location saved */
locationUuids?: Set<string>;
}
export function getAgentColumns({
role,
uptimeMap,
storageMap = {},
managedUsers,
viewAsLoading,
setDetailAgent,
setAccountModalOpen,
setAccountModalAgent,
handleViewAs,
openDelete,
handleEditLocation,
handleEditSubnet,
locationUuids = new Set(),
}: GetColumnsProps): Column<Agent>[] {
const cols: Column<Agent>[] = [
{
header: "UUID / Serial",
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>
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
</button>
)
},
{
header: "Label",
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"
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') && (
<Badge variant="orange" className="text-[10px] px-1.5 py-0.5 font-semibold shrink-0 whitespace-nowrap">No account yet</Badge>
)}
</div>
);
}
},
{
header: "Provisioned",
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: "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: "Traffic Status",
className: "w-[15%] text-center",
accessor: (row) => {
const statusText = row.last_seen_at?.human || '-';
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 font-normal">{statusText}</span>;
}
}
},
{
header: "Avg Uptime",
className: "w-[10%] text-center",
accessor: (row) => {
const uuid = row.uuid || row.serial;
const uptime = uptimeMap[uuid] ?? 100;
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-medium text-xs tracking-wide ${color}`}>{uptime.toFixed(1)}%</span>;
}
},
{
header: "Actions",
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 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') && (
<>
<button
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>
);
})()}
{handleEditSubnet && (
<button
onClick={(e) => { e.stopPropagation(); handleEditSubnet(uuid, agentLabel); }}
className="p-1 text-violet-400 hover:text-violet-300 hover:bg-violet-950/30 rounded transition-colors"
title="Konfigurasi subnet IP agen ini"
>
<Network className="w-4 h-4" />
</button>
)}
<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"
>
{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 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>
);
}
},
];
if (role === 'SUPER_ADMIN') {
cols.splice(cols.length - 1, 0, {
header: "DB Storage",
className: "w-[10%] text-center",
accessor: (row) => {
const uuid = row.uuid || row.serial;
let sizeMB = storageMap[uuid] ?? 0;
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="text-slate-400 font-mono text-xs">{displaySize}</span>;
}
});
}
return cols;
}
@@ -0,0 +1,85 @@
"use client";
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";
export function getExternalAccountColumns(
onManageAccount: (user: ManagedUser) => void
): Column<ManagedUser>[] {
return [
{
header: "Account Name",
className: "w-[25%] text-left",
accessor: (row) => (
<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 ? (
<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>
)}
</div>
<span className="font-medium text-white">{row.account_name || "-"}</span>
</div>
)
},
{
header: "Username",
className: "w-[25%] text-left",
accessor: (row) => <span className="text-slate-300 font-mono text-sm">{row.username}</span>
},
{
header: "Role",
className: "w-[20%] text-center",
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";
return (
<span className={`px-2.5 py-1 rounded-full text-xs font-semibold border ${badgeColor}`}>
{row.role.replace('_', ' ')}
</span>
);
}
},
{
header: "Status",
className: "w-[15%] text-center",
accessor: (row) => (
<div className="flex items-center justify-center gap-2">
{(row as any).is_active !== false ? (
<Badge variant="green"><CheckCircle2 className="w-3 h-3 mr-1" /> Active</Badge>
) : (
<Badge variant="red"><XCircle className="w-3 h-3 mr-1" /> Inactive</Badge>
)}
</div>
)
},
{
header: "Actions",
className: "w-[15%] text-center",
accessor: (row) => (
<button
onClick={() => onManageAccount(row)}
className="p-1.5 text-blue-400 hover:text-blue-300 hover:bg-blue-950/30 rounded transition-colors disabled:opacity-50"
title="Manage Account"
disabled={row.role === 'SUPER_ADMIN'}
>
<UserCog className="w-4 h-4" />
</button>
)
}
];
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { useEffect, useState } 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 UptimeStatsCards from "./UptimeStatsCards";
import AgentsTableSection from "./AgentsTableSection";
import ExternalAccountsSection from "./ExternalAccountsSection";
import AgentsPageModals from "./AgentsPageModals";
import AgentLocationMap from "@/components/admin/AgentLocationMap";
import { SubnetConfigModal } from "@/components/admin/SubnetConfigModal";
import { resolveAgentLabel } from "@/lib/admin-api";
export default function AgentsPage() {
useEffect(() => {
document.title = "Agents | BackOne - Deep Package Inspection";
}, []);
const {
role,
agents,
uptimeMap,
storageMap,
isLoading,
error,
isCreateOpen,
setIsCreateOpen,
isDeleteOpen,
setIsDeleteOpen,
detailAgent,
setDetailAgent,
managedUsers,
accountModalOpen,
setAccountModalOpen,
accountModalAgent,
setAccountModalAgent,
externalModalOpen,
setExternalModalOpen,
externalModalUser,
setExternalModalUser,
targetSiteUuid,
setTargetSiteUuid,
targetCreatedBy,
setTargetCreatedBy,
locations,
isLocModalOpen,
setIsLocModalOpen,
locModalAgentUuid,
setLocModalAgentUuid,
locModalAgentLabel,
setLocModalAgentLabel,
locModalCurrentLoc,
setLocModalCurrentLoc,
viewAsLoading,
selectedAgent,
loadAgents,
loadLocations,
handleViewAs,
openDelete,
handleEditLocation,
} = useAgentsData();
const [subnetModalOpen, setSubnetModalOpen] = useState(false);
const [subnetModalAgent, setSubnetModalAgent] = useState<{ uuid: string; label: string } | null>(null);
const handleEditSubnet = (agentUuid: string, agentLabel: string) => {
setSubnetModalAgent({ uuid: agentUuid, label: agentLabel });
setSubnetModalOpen(true);
};
const columns = getAgentColumns({
role,
uptimeMap,
storageMap,
managedUsers,
viewAsLoading,
setDetailAgent,
setAccountModalOpen,
setAccountModalAgent,
handleViewAs,
openDelete,
handleEditLocation,
handleEditSubnet,
locationUuids: new Set(locations.map((l: any) => l.agent_uuid)),
});
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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>Agents Inventory</span>
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="agents" />
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
Managed DPI probes, remote collectors, and probe infrastructure.
</p>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="agents" />
</div>
</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>
)}
<UptimeStatsCards
totalAgents={totalAgents}
onlineAgents={onlineAgents}
offlineAgents={offlineAgents}
avgUptime={avgUptime}
/>
{/* 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)}
/>
{(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}
setIsCreateOpen={setIsCreateOpen}
isDeleteOpen={isDeleteOpen}
setIsDeleteOpen={setIsDeleteOpen}
selectedAgent={selectedAgent}
loadAgents={loadAgents}
detailAgent={detailAgent}
setDetailAgent={setDetailAgent}
accountModalOpen={accountModalOpen}
setAccountModalOpen={setAccountModalOpen}
accountModalAgent={accountModalAgent}
setAccountModalAgent={setAccountModalAgent}
managedUsers={managedUsers}
externalModalOpen={externalModalOpen}
setExternalModalOpen={setExternalModalOpen}
externalModalUser={externalModalUser}
setExternalModalUser={setExternalModalUser}
targetSiteUuid={targetSiteUuid}
setTargetSiteUuid={setTargetSiteUuid}
targetCreatedBy={targetCreatedBy}
setTargetCreatedBy={setTargetCreatedBy}
isLocModalOpen={isLocModalOpen}
setIsLocModalOpen={setIsLocModalOpen}
locModalAgentUuid={locModalAgentUuid}
setLocModalAgentUuid={setLocModalAgentUuid}
locModalAgentLabel={locModalAgentLabel}
setLocModalAgentLabel={setLocModalAgentLabel}
locModalCurrentLoc={locModalCurrentLoc}
setLocModalCurrentLoc={setLocModalCurrentLoc}
loadLocations={loadLocations}
/>
<SubnetConfigModal
agentUuid={subnetModalAgent?.uuid || ""}
agentLabel={subnetModalAgent?.label || ""}
open={subnetModalOpen}
onClose={() => setSubnetModalOpen(false)}
/>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
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";
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>>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
const [managedUsers, setManagedUsers] = useState<ManagedUser[]>([]);
const [accountModalOpen, setAccountModalOpen] = useState(false);
const [accountModalAgent, setAccountModalAgent] = useState<{ uuid: string; label: string } | null>(null);
const [externalModalOpen, setExternalModalOpen] = useState(false);
const [externalModalUser, setExternalModalUser] = useState<ManagedUser | null>(null);
const [targetSiteUuid, setTargetSiteUuid] = useState<string | null>(null);
const [targetCreatedBy, setTargetCreatedBy] = useState<string | null>(null);
const [selectedSite, setSelectedSite] = useState<string>("6681452d_9cae_4ff4_8ae8_0d504774265e");
const [locations, setLocations] = useState<any[]>([]);
const [isLocModalOpen, setIsLocModalOpen] = useState(false);
const [locModalAgentUuid, setLocModalAgentUuid] = useState("");
const [locModalAgentLabel, setLocModalAgentLabel] = useState("");
const [locModalCurrentLoc, setLocModalCurrentLoc] = useState<any | null>(null);
useEffect(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('backone_site_uuid');
if (stored) setSelectedSite(stored);
}
}, []);
const [viewAsLoading, setViewAsLoading] = useState<string | null>(null);
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const { timeRange } = useTimeFilter();
const refreshKey = usePollingKey(60000); // Auto-refresh every 60 seconds (1 minute)
const loadUptime = async () => {
try {
const res = await fetch(`/api/dashboard/agents/uptime?timeRange=${timeRange}`, {
headers: getViewAsHeaders()
});
if (res.ok) {
const json = await res.json();
if (json.ok && json.uptime) setUptimeMap(json.uptime);
}
} catch (err) {
console.error("Failed to load uptime stats:", err);
}
};
const loadStorage = async () => {
try {
const res = await fetch('/api/dashboard/agents/storage', {
headers: getViewAsHeaders()
});
if (res.ok) {
const json = await res.json();
if (json.ok && json.storage) setStorageMap(json.storage);
}
} catch (err) {
console.error("Failed to load agent storage stats:", err);
}
};
const loadLocations = async () => {
try {
const res = await fetch("/api/dashboard/agent-locations", {
headers: getViewAsHeaders(),
});
if (res.ok) {
const json = await res.json();
if (json.ok && json.data) setLocations(json.data);
}
} catch (err) {
console.error("Failed to load locations:", err);
}
};
const loadAgents = async (allowedUuids?: string[], currentRole?: string) => {
setIsLoading(true);
try {
const siteUuid = localStorage.getItem("backone_site_uuid") || undefined;
const [agentData, userData] = await Promise.allSettled([
getAgents(siteUuid),
getAdminUsers(),
]);
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();
} catch (e: any) {
setError(e.message);
}
setIsLoading(false);
};
const handleViewAs = async (agentUuid: string, agentLabel: string) => {
setViewAsLoading(agentUuid);
try {
await startViewAs(agentUuid, agentLabel);
router.refresh();
window.location.reload();
} catch (err: any) {
alert('Failed to enter View As mode: ' + err.message);
} finally {
setViewAsLoading(null);
}
};
useEffect(() => {
fetch('/api/auth/me', { headers: getViewAsHeaders() })
.then(res => res.json())
.then(data => {
if (data.user) {
const userRole = data.user.role || null;
const uuids: string[] = data.user.agent_uuids || [];
setRole(userRole);
setMyAgentUuids(uuids);
const allowed = ['SUPER_ADMIN', 'TENANT_ADMIN', 'EXECUTIVE', 'COMPANY_ADMIN', 'COMPANY_OPERATOR'];
if (!allowed.includes(userRole || '')) {
router.push('/');
} else {
loadAgents(uuids, userRole);
}
} else {
setIsLoading(false);
}
})
.catch(err => {
console.error("Auth check failed:", err);
setIsLoading(false);
});
}, [router]);
useEffect(() => {
if (role && role !== 'AGENT_VIEWER') loadUptime();
}, [timeRange]);
useEffect(() => {
if (role === 'SUPER_ADMIN') {
loadStorage();
}
if (role && role !== 'AGENT_VIEWER') {
loadLocations();
}
}, [role]);
useEffect(() => {
if (role && role !== 'AGENT_VIEWER' && refreshKey > 0) {
loadAgents();
if (role === 'SUPER_ADMIN') {
loadStorage();
}
}
}, [refreshKey, role]);
const openDelete = (agent: Agent) => {
setSelectedAgent(agent);
setIsDeleteOpen(true);
};
const handleEditLocation = (agentUuid: string, agentLabel: string) => {
const loc = locations.find(l => l.agent_uuid === agentUuid) || null;
setLocModalAgentUuid(agentUuid);
setLocModalAgentLabel(agentLabel);
setLocModalCurrentLoc(loc);
setIsLocModalOpen(true);
};
return {
role,
agents,
uptimeMap,
storageMap,
isLoading,
error,
isCreateOpen,
setIsCreateOpen,
isDeleteOpen,
setIsDeleteOpen,
detailAgent,
setDetailAgent,
managedUsers,
accountModalOpen,
setAccountModalOpen,
accountModalAgent,
setAccountModalAgent,
externalModalOpen,
setExternalModalOpen,
externalModalUser,
setExternalModalUser,
targetSiteUuid,
setTargetSiteUuid,
targetCreatedBy,
setTargetCreatedBy,
locations,
isLocModalOpen,
setIsLocModalOpen,
locModalAgentUuid,
setLocModalAgentUuid,
locModalAgentLabel,
setLocModalAgentLabel,
locModalCurrentLoc,
setLocModalCurrentLoc,
viewAsLoading,
selectedAgent,
loadAgents,
loadLocations,
handleViewAs,
openDelete,
handleEditLocation,
};
}
+247
View File
@@ -0,0 +1,247 @@
"use client";
import { useState, useEffect } from "react";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { AppStat } from "@/lib/api";
import { useTopApps } from "@/lib/api-with-context";
import { DataTable, Column } from "@/components/ui/DataTable";
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";
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 {
filters, dateFrom, dateTo,
handleFilterChange, setDateFrom, setDateTo,
handleReset, activeCount
} = useUniversalFilterState();
useEffect(() => {
setMounted(true);
document.title = "Apps | BackOne - Deep Package Inspection";
}, []);
if (!mounted) return null;
const all = 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: "app_label", label: "Application", type: "select", value: filters.app_label, options: opts("app_label"), formatter: formatAppLabel },
{ id: "category", label: "Category", type: "select", value: filters.category, options: opts("category") },
{ 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"] },
{ id: "sort_total", label: "Sort Total", type: "select", value: filters.sort_total, options: ["Descending", "Ascending"] },
];
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;
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";
const sortTotal = filters.sort_total || "All";
if (sortTotal !== "All") {
processedData.sort((a, b) => sortTotal === "Descending" ? ((b.upload ?? 0) + (b.download ?? 0)) - ((a.upload ?? 0) + (a.download ?? 0)) : ((a.upload ?? 0) + (a.download ?? 0)) - ((b.upload ?? 0) + (b.download ?? 0)));
} else if (sortUpload !== "All") {
processedData.sort((a, b) => sortUpload === "Descending" ? (b.upload ?? 0) - (a.upload ?? 0) : (a.upload ?? 0) - (b.upload ?? 0));
} else if (sortDownload !== "All") {
processedData.sort((a, b) => sortDownload === "Descending" ? (b.download ?? 0) - (a.download ?? 0) : (a.download ?? 0) - (b.download ?? 0));
}
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);
const columns: Column<AppStat>[] = [
{
header: "Last Seen",
accessor: (row) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
},
{ header: "#", accessor: (row, i) => i + 1 },
{
header: "Application",
accessor: (row) => (
<button
onClick={() => setSelectedApp(row)}
className="flex items-center gap-2 group text-left"
title="Click to view app details"
>
{(() => {
const fav = row.favicon || getAppFavicon(row.app_label);
return fav ? (
<img src={fav} alt={row.app_label} className="w-5 h-5 rounded-sm bg-white p-0.5 flex-shrink-0" />
) : (
<div className="w-5 h-5 rounded bg-blue-500/10 border border-blue-500/25 flex items-center justify-center flex-shrink-0">
<Globe className="w-3 h-3 text-blue-400" />
</div>
);
})()}
<span className="group-hover:text-blue-300 transition-colors">{formatAppLabel(row.app_label)}</span>
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
</button>
),
},
{ header: "Category", accessor: (row) => row.category || "-" },
{
header: "Download",
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>,
},
{
header: "Upload",
accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>
},
{
header: "Total",
accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span>
},
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>Apps</span>
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="apps" />
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
Click any application to see which agents and devices are using it.
</p>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="apps" />
</div>
</div>
<UniversalFilters
filters={filterConfigs}
onChange={(id, val) => {
if (id === 'sort_download') {
handleFilterChange('sort_download', val);
handleFilterChange('sort_upload', 'All');
handleFilterChange('sort_total', 'All');
} else if (id === 'sort_upload') {
handleFilterChange('sort_upload', val);
handleFilterChange('sort_download', 'All');
handleFilterChange('sort_total', 'All');
} else if (id === 'sort_total') {
handleFilterChange('sort_total', val);
handleFilterChange('sort_download', 'All');
handleFilterChange('sort_upload', '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/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="text-white">Top 5 Applications by Volume</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={top5} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" vertical={false} />
<XAxis dataKey="app_label" stroke="#8b949e" tickLine={false} axisLine={false} />
<YAxis
stroke="#8b949e"
tickLine={false}
axisLine={false}
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" }}
contentStyle={{ backgroundColor: "#161b22", borderColor: "#30363d", color: "#c9d1d9" }}
formatter={(value: any) => [fmtBytes(value as number), "Volume"]}
/>
<Bar dataKey="download" name="Download" fill="#58a6ff" radius={[4, 4, 0, 0]} />
<Bar dataKey="upload" name="Upload" fill="#3fb950" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="text-white">All Applications ({processedData.length})</CardTitle>
</CardHeader>
<CardContent>
<DataTable
data={processedData}
columns={columns}
searchPlaceholder="Search by application or category..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.app_label || "").toLowerCase().includes(q) ||
(row.category || "").toLowerCase().includes(q)
);
}}
/>
</CardContent>
</Card>
{/* App Detail Modal */}
{selectedApp && (
<AppDetailModal
appLabel={selectedApp.app_label}
favicon={selectedApp.favicon}
category={selectedApp.category}
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,185 @@
"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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>Device Labeling</span>
<Tag className="w-5 h-5 sm:w-6 sm:h-6 text-primary flex-shrink-0" />
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="device-labeling" />
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
Assign custom names to MAC addresses to identify device owners across all charts and flows.
</p>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="device-labeling" />
</div>
</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>
);
}
+281
View File
@@ -0,0 +1,281 @@
"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 } 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 } 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 devices = useDevices();
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
const [agentsMap, setAgentsMap] = useState<Record<string, string>>({});
useEffect(() => {
fetch('/api/dashboard/agents/list')
.then(res => res.json())
.then(json => {
if (json.ok && json.data) {
const map: Record<string, string> = {};
json.data.forEach((a: any) => { map[a.uuid] = a.label; });
setAgentsMap(map);
}
}).catch(() => {});
}, []);
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: "agent_uuid",
label: "Agent",
type: "select",
value: filters.agent_uuid,
options: opts("agent_uuid"),
formatter: (val) => val === "All" ? "All" : agentsMap[val] || val
},
{ 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: "Agent",
accessor: (row) => (
<span className="text-xs text-slate-300">
{agentsMap[row.agent_uuid] || row.agent_uuid || "-"}
</span>
),
className: "w-[12%]"
},
{
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.agent_uuid && filters.agent_uuid !== "All" && row.agent_uuid !== filters.agent_uuid) return false;
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-6 sm:space-y-8 animate-fade-in pb-10">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 flex-wrap">
<span>Devices</span>
{devices.isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
Monitor and manage connected network devices.{" "}
<span className="text-slate-500 text-[10px] sm:text-xs block sm:inline mt-0.5 sm:mt-0">Click an IP to see app usage &amp; flows.</span>
</p>
</div>
<HelpTrigger pageId="devices" className="flex-shrink-0" />
</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>
);
}
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { useState, useEffect } from "react";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { useDNS } 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 { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function DNSPage() {
const [mounted, setMounted] = useState(false);
const { data, isLoading } = useDNS();
const {
filters,
handleFilterChange,
handleReset, activeCount
} = useUniversalFilterState();
const [selectedDomain, setSelectedDomain] = useState<string | null>(null);
const [detailData, setDetailData] = useState<any[]>([]);
const [isDetailLoading, setIsDetailLoading] = useState(false);
useEffect(() => {
setMounted(true);
document.title = "DNS | BackOne - Deep Package Inspection";
}, []);
useEffect(() => {
if (selectedDomain) {
setIsDetailLoading(true);
fetch(`http://localhost:4000/domain-details?domain=${encodeURIComponent(selectedDomain)}`)
.then(res => res.json())
.then(data => {
if (data.ok) {
setDetailData(data.data || []);
}
})
.catch(console.error)
.finally(() => setIsDetailLoading(false));
}
}, [selectedDomain]);
if (!mounted) return null;
const all = 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[] = []; // Removed App/Category filters
const filteredData = all;
const columns: Column<typeof data[0]>[] = [
{
header: "Last Seen",
accessor: (row) => <TimestampCell timestamp={row.last_seen || row.timestamp} showActiveStatus={true} />
},
{ header: "#", accessor: (row, i) => i + 1 },
{
header: "Domain",
accessor: (row) => <span className="font-medium text-white">{row.domain}</span>
},
{
header: "Label (Translation)",
accessor: (row) => {
const d = (row.domain || "").toLowerCase();
let label = "-";
if (d.includes('facebook') || d.includes('fbcdn')) label = 'Facebook';
else if (d.includes('google') || d.includes('1e100.net')) label = 'Google / Android';
else if (d.includes('apple') || d.includes('icloud') || d.includes('aaplimg')) label = 'Apple / iOS';
else if (d.includes('tiktok') || d.includes('byte')) label = 'TikTok';
else if (d.includes('whatsapp') || d.includes('wa.net')) label = 'WhatsApp';
else if (d.includes('instagram') || d.includes('cdninstagram')) label = 'Instagram';
else if (d.includes('microsoft') || d.includes('msedge') || d.includes('sfx.ms')) label = 'Microsoft';
else if (d.includes('netflix') || d.includes('nflx')) label = 'Netflix';
else if (d.includes('amazon') || d.includes('aws')) label = 'Amazon (AWS)';
else if (d.includes('cloudflare')) label = 'Cloudflare';
else if (d.includes('akamai')) label = 'Akamai CDN';
else if (d.includes('fastly')) label = 'Fastly CDN';
else if (d.includes('github')) label = 'GitHub';
else if (d.includes('twimg') || d.includes('twitter') || d.includes('x.com')) label = 'X (Twitter)';
else if (d.includes('grafana')) label = 'Grafana';
return <span className="text-gray-300 bg-gray-800 px-2 py-1 rounded-md text-xs">{label}</span>;
}
},
{
header: "Total Connections",
accessor: (row) => <span className="text-purple-400">{row.query_count?.toLocaleString() || 0}</span>
}
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>DNS</span>
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="dns" />
</div>
</div>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="dns" />
</div>
</div>
<UniversalFilters
filters={filterConfigs}
onChange={handleFilterChange}
showDateRange={false}
activeFilterCount={activeCount}
onReset={handleReset}
/>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="text-white">All DNS Queries</CardTitle>
</CardHeader>
<CardContent>
<DataTable
data={filteredData}
columns={columns}
searchPlaceholder="Search by domain, application, or category..."
onRowClick={(row) => setSelectedDomain(row.domain)}
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.domain || "").toLowerCase().includes(q) ||
(row.app_label || "").toLowerCase().includes(q) ||
(row.category || "").toLowerCase().includes(q)
);
}}
/>
</CardContent>
</Card>
{selectedDomain && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-card w-full max-w-3xl rounded-xl border border-border shadow-2xl flex flex-col max-h-[85vh] animate-in fade-in zoom-in duration-200">
<div className="flex items-center justify-between p-6 border-b border-border/50">
<div>
<h3 className="text-xl font-bold text-white mb-1">Domain Access Details</h3>
<p className="text-sm text-muted-foreground break-all">{selectedDomain}</p>
</div>
<button
onClick={() => setSelectedDomain(null)}
className="text-muted-foreground hover:text-white p-2 rounded-lg hover:bg-white/5 transition-colors"
>
✕
</button>
</div>
<div className="p-6 overflow-y-auto">
{isDetailLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 className="w-8 h-8 animate-spin mb-4 text-primary" />
<p>Fetching real-time access logs from proxy...</p>
<p className="text-xs opacity-60 mt-2 text-center max-w-sm">
⚠️ Ignoring Rule #13 temporarily. This performs a live on-demand query to the upstream API.
</p>
</div>
) : detailData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground text-center">
<p className="text-lg font-medium text-white mb-2">No IP details found</p>
<p className="max-w-md">The upstream API did not return any identifiable IPs or MACs actively connecting to this domain at this exact moment.</p>
</div>
) : (
<div className="rounded-lg border border-border/50 overflow-hidden">
<table className="w-full text-sm text-left">
<thead className="bg-secondary/30 text-muted-foreground">
<tr>
<th className="px-4 py-3 font-medium">Device Name</th>
<th className="px-4 py-3 font-medium">IP Address</th>
<th className="px-4 py-3 font-medium">MAC Address</th>
<th className="px-4 py-3 font-medium text-right">Last Seen</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{detailData.map((d, i) => (
<tr key={i} className="hover:bg-white/5 transition-colors">
<td className="px-4 py-3 text-white">{d.deviceName}</td>
<td className="px-4 py-3 text-blue-400">{d.ip}</td>
<td className="px-4 py-3 text-purple-400 font-mono text-xs">{d.mac}</td>
<td className="px-4 py-3 text-right">
<TimestampCell timestamp={d.timestamp} showActiveStatus={true} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
@@ -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>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import {
useNetbios, useDiscoveryOs, useDhcpFingerprints,
useHttpUserAgents, useSniHostnames, useSslServerCn,
useQuicHostnames, useBittorrentHashes, useSshVersions,
useMdnsHostnames
} from "@/lib/api-advanced";
import { MetadataDetailPanel } from "@/components/dpi/MetadataDetailPanel";
import { MetadataDetailType } from "@/lib/api-metadata-detail";
import { useTimeFilter } from "@/contexts/TimeFilterContext";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import DpiAnalyticsTabs from "./DpiAnalyticsTabs";
export default function DpiAnalyticsPage() {
const { timeRange } = useTimeFilter();
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);
setDetailValue(value);
}, []);
const closeDetail = useCallback(() => {
setDetailType(null);
setDetailValue(null);
}, []);
// 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 animate-fade-in">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white tracking-tight truncate">
DPI MetaData
</h1>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="dpi-analytics" />
</div>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">
Deep Packet Inspection metadata and fingerprinting.{" "}
<span className="text-blue-400 text-[10px] sm:text-xs block sm:inline mt-0.5 sm:mt-0">Click any row to view device-level detail.</span>
</p>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="dpi-analytics" />
</div>
</div>
<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}
/>
<MetadataDetailPanel
type={detailType}
value={detailValue}
timeRange={timeRange}
onClose={closeDetail}
/>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import { useState, useEffect } from "react";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { useEvents } from "@/lib/api-with-context";
import { DataTable, Column } from "@/components/ui/DataTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
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");
const {
filters, dateFrom, dateTo,
handleFilterChange, setDateFrom, setDateTo,
handleReset, activeCount
} = useUniversalFilterState();
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const all = 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: "severity", label: "Severity", type: "select", value: filters.severity, options: opts("severity") },
{ id: "event_type", label: "Event Type", type: "select", value: filters.event_type, options: opts("event_type") },
{ id: "source_ip", label: "Source IP", type: "select", value: filters.source_ip, options: opts("source_ip") },
{ id: "mac_address", label: "MAC Address", type: "select", value: filters.mac_address, options: opts("mac_address") },
];
const filteredData = all.filter((row: any) => {
if (filters.severity && filters.severity !== "All" && row.severity !== filters.severity) return false;
if (filters.event_type && filters.event_type !== "All" && row.event_type !== filters.event_type) return false;
if (filters.source_ip && filters.source_ip !== "All" && row.source_ip !== filters.source_ip) return false;
if (filters.mac_address && filters.mac_address !== "All" && row.mac_address !== filters.mac_address) return false;
if (dateFrom || dateTo) {
const d = row.timestamp ? new Date(row.timestamp).toISOString().slice(0, 10) : "";
if (dateFrom && d < dateFrom) return false;
if (dateTo && d > dateTo) return false;
}
return true;
});
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()) {
case 'critical':
case 'high':
return <AlertCircle className="w-4 h-4 text-red-500" />;
case 'medium':
case 'warning':
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
default:
return <Info className="w-4 h-4 text-blue-500" />;
}
};
const columns: Column<typeof data[0]>[] = [
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%] text-center" },
{
header: "Timestamp (WIB)",
accessor: (row) => <TimestampCell timestamp={row.timestamp} showActiveStatus={false} />,
className: "w-[15%] text-center"
},
{
header: "Severity",
accessor: (row) => (
<div className="flex items-center gap-2 justify-center">
{getSeverityIcon(row.severity)}
<span className="capitalize">{row.severity}</span>
</div>
),
className: "w-[10%] text-center"
},
{ 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 truncate"
>
{row.mac_address}
</button>
) : <span className="text-slate-500">-</span>,
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 truncate"
>
{row.source_ip}
</button>
) : <span className="text-slate-500">-</span>,
className: "w-[12%] text-center"
},
{
header: "Message",
accessor: (row) => {
const msg = row.message;
const mac = row.mac_address;
let content;
if (!mac || !msg.includes(mac)) {
content = <span className="text-slate-200 text-sm truncate">{msg}</span>;
} else {
// Highlight MAC in message with teal color
const parts = msg.split(mac);
content = (
<span className="text-slate-200 text-sm truncate">
{parts[0]}
<span className="font-mono text-emerald-400 font-medium">{mac}</span>
{parts[1]}
</span>
);
}
return (
<div title={msg} className="truncate w-full block text-center cursor-help">
{content}
</div>
);
},
className: "w-[33%] text-center"
}
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>Network Events</span>
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="events" />
</div>
</div>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="events" />
</div>
</div>
<UniversalFilters
filters={filterConfigs}
onChange={handleFilterChange}
showDateRange={true}
dateFrom={dateFrom}
dateTo={dateTo}
minDate={minDate}
onDateChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
activeFilterCount={activeCount}
onReset={handleReset}
/>
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
<CardHeader>
<CardTitle className="text-white">Recent Events ({filteredData.length})</CardTitle>
</CardHeader>
<CardContent>
<DataTable
data={filteredData}
columns={columns}
searchPlaceholder="Search by event type, severity, MAC, IP, or message..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (
(row.event_type || "").toLowerCase().includes(q) ||
(row.severity || "").toLowerCase().includes(q) ||
(row.source_ip || "").toLowerCase().includes(q) ||
(row.mac_address || "").toLowerCase().includes(q) ||
(row.message || "").toLowerCase().includes(q)
);
}}
getRowClassName={(row) => {
return highlightId && row.id.toString() === highlightId
? "bg-yellow-500/10 border-l-2 border-yellow-500 animate-pulse font-semibold"
: "";
}}
pageSize={50}
/>
</CardContent>
</Card>
{selectedIp && (
<DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />
)}
{selectedMac && (
<DeviceDetailModal ip="" mac={selectedMac} onClose={() => setSelectedMac(null)} />
)}
</div>
);
}
export default function EventsPage() {
return (
<Suspense fallback={<div className="flex items-center justify-center p-12"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>}>
<EventsContent />
</Suspense>
);
}
+46
View File
@@ -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
};
}
+114
View File
@@ -0,0 +1,114 @@
import React from "react";
import { Column } from "@/components/ui/DataTable";
import { TimestampCell } from "@/components/ui/TimestampCell";
import { fmtBytes } from "@/lib/utils";
import { IpDetails } from "@/components/ui/IpDetails";
import { explainAppOrPort } from "./explainFlow";
export function getFlowColumns(onSelectIp: (ip: string) => void): Column<any>[] {
return [
{
header: "Last Seen",
accessor: (row) => (
<TimestampCell
timestamp={row.last_seen}
firstSeen={row.first_seen}
showActiveStatus={true}
/>
),
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: "Src IP",
accessor: (row) => (
<div className="flex flex-col items-center justify-center text-center">
<button
onClick={() => onSelectIp(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-center justify-center">
<button
onClick={() => onSelectIp(row.dst_ip)}
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: "App / Domain",
accessor: (row) => {
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
return (
<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 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>,
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>,
className: "w-[7%] text-center",
tooltip: (row) => `${fmtBytes(row.bytes_upload)} (${(row.bytes_upload || 0).toLocaleString()} bytes)`
}
];
}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useFlows, useFlowsOptions } from "@/lib/api-with-context";
import { DataTable } from "@/components/ui/DataTable";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Loader2, Download, Search } from "lucide-react";
import { formatAppLabel } from "@/lib/utils";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { downloadCsv } from "@/components/ui/DataTableCsvHelper";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import { getFlowColumns } from "./flowColumns";
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();
// 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";
}, []);
const columns = useMemo(() => getFlowColumns(setSelectedIp), []);
const filterConfigs: FilterConfig[] = useMemo(() => {
const opts = (key: string) => {
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] || [];
};
return [
{ id: "protocol", label: "Protocol", type: "select", value: filters.protocol, options: opts("protocol"), formatter: formatAppLabel },
{ id: "src_ip", label: "Source IP", type: "select", value: filters.src_ip, options: opts("src_ip") },
{ id: "dst_ip", label: "Dest IP", type: "select", value: filters.dst_ip, options: opts("dst_ip") },
{ id: "dst_port", label: "Dest Port", type: "select", value: filters.dst_port, options: opts("dst_port") },
{ id: "app", label: "App", type: "select", value: filters.app, options: opts("app_label"), formatter: formatAppLabel },
{ id: "domain", label: "Domain", type: "select", value: filters.domain, options: opts("domain") },
{ 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"] },
];
}, [optionsData, filters]);
const minDate = useMemo(() => {
return 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);
};
if (!mounted) return null;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>Flows</span>
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="flows" />
</div>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">
Real-time packet inspection and active network connection flows.
</p>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="flows" />
</div>
</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/50 backdrop-blur-sm border-border/50">
<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={flows}
columns={columns}
serverSide={true}
totalCount={total}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</CardContent>
</Card>
{selectedIp && (
<DeviceDetailModal ip={selectedIp} onClose={() => setSelectedIp(null)} />
)}
</div>
);
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useCountries } 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 { fmtBytes } from "@/lib/utils";
import { WorldMap } from "@/components/ui/WorldMap";
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function GeographyPage() {
const [mounted, setMounted] = useState(false);
const [showMap, setShowMap] = useState(false);
const { data, isLoading } = useCountries();
const {
filters,
handleFilterChange,
handleReset, activeCount
} = useUniversalFilterState();
const mapData = useMemo(() => {
if (!data) return [];
return data.map((row) => ({
countryCode: row.country_code,
value: row.download + row.upload,
download: row.download,
upload: row.upload,
label: row.country_name,
}));
}, [data]);
useEffect(() => {
setMounted(true);
document.title = "Geo Traffic | BackOne - Deep Package Inspection";
}, []);
if (!mounted) return null;
const all = 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: "country_name", label: "Country", type: "select", value: filters.country_name, options: opts("country_name") },
{ 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 filteredData = all.filter((row: any) => {
if (filters.country_name && filters.country_name !== "All" && row.country_name !== filters.country_name) 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 columns: Column<typeof data[0]>[] = [
{ header: "#", accessor: (row, i) => i + 1 },
{
header: "Country",
accessor: (row) => (
<div className="flex items-center gap-2">
{row.country_code && (
<img
src={`https://flagcdn.com/w20/${row.country_code.toLowerCase()}.png`}
alt={row.country_code}
className="w-5 h-auto rounded-sm"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
<span>{row.country_name}</span>
</div>
)
},
{ header: "Country Code", accessor: (row) => row.country_code },
{
header: "Download",
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>
},
{
header: "Upload",
accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>
},
{
header: "Total Data",
accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span>
}
];
return (
<div className="space-y-8 animate-fade-in pb-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>Geo Traffic</span>
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="geography" />
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
Visualize data flow and activity across the globe.
</p>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="geography" />
</div>
</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={false}
activeFilterCount={activeCount}
onReset={handleReset}
/>
{showMap && (
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '100ms' }}>
<CardHeader className="flex flex-row items-center justify-between border-b border-border/50 pb-4 mb-4">
<CardTitle className="text-lg text-white">Heatmap Visualization</CardTitle>
<button
onClick={() => setShowMap(false)}
className="px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-400 border border-red-500/50 rounded-lg transition-colors flex items-center gap-2 text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
Close Heatmap
</button>
</CardHeader>
<CardContent className="p-1 sm:p-4 pt-0">
<WorldMap data={mapData} />
</CardContent>
</Card>
)}
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '200ms' }}>
<CardHeader className="flex flex-row items-center justify-between border-b border-border/50 pb-4 mb-4">
<CardTitle className="text-lg text-white">Traffic by Country ({processedData.length})</CardTitle>
{!showMap && (
<button
onClick={() => setShowMap(true)}
className="px-4 py-2 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 rounded-lg transition-all shadow-[0_0_15px_rgba(59,130,246,0.15)] flex items-center gap-2 text-sm font-medium"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
Load Geographic Heatmap
</button>
)}
</CardHeader>
<CardContent>
<DataTable
data={processedData}
columns={columns}
searchPlaceholder="Search by country name..."
searchFilter={(row, query) => {
const q = query.toLowerCase();
return (row.country_name || "").toLowerCase().includes(q);
}}
/>
</CardContent>
</Card>
</div>
);
}
@@ -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 },
]}
/>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { useState, useEffect } from "react";
import { useIntelligenceStats } from "@/lib/api-with-context";
import {
useIntelCryptoMining, useIntelTorDetection, useIntelVpnDetection,
useIntelIpReputation, useIntelInsecureProtocols, useIntelUnencryptedPasswords,
useIntelEncryptionAudit, useIntelDeviceDiscovery, useIntelServerDiscovery
} from "@/lib/api-advanced";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Network, ShieldAlert } from "lucide-react";
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
import { HelpTrigger } from "@/components/help/HelpTrigger";
import { IntelligenceTables } from "./IntelligenceTables";
export default function IntelligencePage() {
const [mounted, setMounted] = useState(false);
const { data: stats, isLoading: loadingStats } = useIntelligenceStats();
const [activeTab, setActiveTab] = useState<string>("Crypto Mining");
const [selectedDevice, setSelectedDevice] = useState<{ip: string, mac?: string} | null>(null);
// 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;
const intelItems = [
{
title: "Crypto Mining",
value: stats?.intel_crypto_mining || 0,
icon: <Bitcoin className="h-5 w-5 text-yellow-500" />,
description: "Devices actively mining cryptocurrency"
},
{
title: "Tor Detection",
value: stats?.intel_tor_detection || 0,
icon: <Globe className="h-5 w-5 text-purple-500" />,
description: "Connections using the Tor network"
},
{
title: "VPN Detection",
value: stats?.intel_vpn_detection || 0,
icon: <Shield className="h-5 w-5 text-blue-500" />,
description: "Active Virtual Private Network tunnels"
},
{
title: "IP Reputation",
value: stats?.intel_ip_reputation || 0,
icon: <ShieldAlert className="h-5 w-5 text-red-500" />,
description: "Connections to malicious IP addresses"
},
{
title: "Insecure Protocols",
value: stats?.intel_insecure_protocols || 0,
icon: <AlertTriangle className="h-5 w-5 text-orange-500" />,
description: "Legacy clear-text protocols"
},
{
title: "Unencrypted Passwords",
value: stats?.intel_unencrypted_passwords || 0,
icon: <Key className="h-5 w-5 text-red-400" />,
description: "Credentials transmitted in plain text"
},
{
title: "Encryption Audit",
value: stats?.intel_encryption_audit || 0,
icon: <Lock className="h-5 w-5 text-green-500" />,
description: "Encrypted connections audited"
},
{
title: "Device Discovery",
value: stats?.intel_device_discovery || 0,
icon: <Network className="h-5 w-5 text-cyan-500" />,
description: "Unique client devices identified"
},
{
title: "Server Discovery",
value: stats?.intel_server_discovery || 0,
icon: <Server className="h-5 w-5 text-gray-400" />,
description: "Local servers running services"
}
];
return (
<div className="space-y-6 animate-fade-in">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>Threat Intelligence</span>
{loadingStats && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="intelligence" />
</div>
</div>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="intelligence" />
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{intelItems.map((item, idx) => {
const isActive = activeTab === item.title;
return (
<Card
key={idx}
onClick={() => setActiveTab(item.title)}
className={`
bg-card/50 backdrop-blur-sm border-border/50 hover:bg-card/80 transition-all cursor-pointer
${isActive ? 'ring-2 ring-primary border-transparent' : ''}
`}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-white">{item.title}</CardTitle>
{item.icon}
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-white mb-1">
{item.value.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</CardContent>
</Card>
);
})}
</div>
<Card>
<CardHeader>
<CardTitle>Detailed Report: {activeTab}</CardTitle>
</CardHeader>
<CardContent>
<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>
{selectedDevice && (
<DeviceDetailModal
ip={selectedDevice.ip}
mac={selectedDevice.mac}
onClose={() => setSelectedDevice(null)}
/>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { DashboardLayout } from "@/components/layout/DashboardLayout";
import { TimeFilterProvider } from "@/contexts/TimeFilterContext";
import { TenantConfigProvider } from "@/contexts/TenantConfigContext";
export default function AppLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<TenantConfigProvider>
<TimeFilterProvider>
<DashboardLayout>{children}</DashboardLayout>
</TimeFilterProvider>
</TenantConfigProvider>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useState, useEffect } from "react";
import { Tabs, Tab } from "@/components/ui/Tabs";
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">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white tracking-tight truncate">
App Lookup
</h1>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="lookup" />
</div>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">
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>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="lookup" />
</div>
</div>
<Tabs>
<Tab label="App Catalog">
<ApplicationCatalog />
</Tab>
<Tab label="Blacklist Configuration">
<BlacklistConfiguration />
</Tab>
</Tabs>
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import { useState, useEffect } 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 {
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";
import { RoutingGeoTab } from "@/components/network/RoutingGeoTab";
import { fmtBytes } from "@/lib/utils";
export default function NetworkInfrastructurePage() {
const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null);
useEffect(() => {
document.title = "Network Topology | BackOne - Deep Package Inspection";
}, []);
const { data: interfaces, isLoading: loadingInterfaces } = useInterfaces();
const { data: vlans, isLoading: loadingVlans } = useVlans();
const { data: remoteIps, isLoading: loadingRemoteIps } = useRemoteIps();
const { data: ipVersions, isLoading: loadingIpVersions } = useIpVersions();
const { data: macBandwidth, isLoading: loadingMacBandwidth } = useMacBandwidth();
const { data: flowTypes, isLoading: loadingFlowTypes } = useFlowTypes();
const { data: flowOrigins, isLoading: loadingFlowOrigins } = useFlowOrigins();
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white tracking-tight truncate">Network Topology</h1>
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="network-infrastructure" />
</div>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">Physical, logical, and geographic network topologies.</p>
</div>
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="network-infrastructure" />
</div>
</div>
<Tabs>
<Tab label="Routing & Geography">
<RoutingGeoTab />
</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) => fmtBytes(row.download) },
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
{ header: "Total", accessor: (row) => fmtBytes(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) => fmtBytes(row.download) },
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
{ header: "Total", accessor: (row) => fmtBytes(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) => fmtBytes(row.download) },
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
{ header: "Total", accessor: (row) => fmtBytes(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) => fmtBytes(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) => fmtBytes(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) => fmtBytes(row.download) },
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
{ header: "Total", accessor: (row) => fmtBytes(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) => fmtBytes(row.download) },
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
{ header: "Total", accessor: (row) => fmtBytes(row.total) },
]}
/>
</CardContent>
</Card>
</div>
</Tab>
</Tabs>
{selectedRemoteIp && (
<RemoteIpDetailModal
ip={selectedRemoteIp}
onClose={() => setSelectedRemoteIp(null)}
/>
)}
</div>
);
}
@@ -0,0 +1,216 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { DataTable, Column } from "@/components/ui/DataTable";
import { useState, useEffect } from "react";
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer,
PieChart, Pie, Cell
} from "recharts";
import { Activity, Loader2 } from "lucide-react";
import { AppCategoryStat } from "@/lib/api";
import { useAppCategories, useContinents } from "@/lib/api-with-context";
import { fmtBytes } from "@/lib/utils";
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { CategoryDetailPanel } from "@/components/network/CategoryDetailPanel";
import { HelpTrigger } from "@/components/help/HelpTrigger";
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
export default function NetworkIntelligencePage() {
const [mounted, setMounted] = useState(false);
const appCategories = useAppCategories();
const continents = useContinents();
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const {
filters,
handleFilterChange,
handleReset, activeCount
} = useUniversalFilterState();
useEffect(() => {
setMounted(true);
document.title = "Traffic Categories | BackOne - Deep Package Inspection";
}, []);
if (!mounted) return null;
const allCategories = appCategories.data || [];
const opts = (key: string) => {
const raw = Array.from(new Set(allCategories.map((r: any) => String(r[key] || "")).filter(Boolean)));
return raw.sort();
};
const filterConfigs: FilterConfig[] = [
{ id: "category_label", label: "Category", type: "select", value: filters.category_label, options: opts("category_label") },
{ 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 filteredData = allCategories.filter((row: any) => {
if (filters.category_label && filters.category_label !== "All" && row.category_label !== filters.category_label) 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 appCols: Column<AppCategoryStat>[] = [
{ 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 animate-pulse-subtle">
<span className="border-b border-dashed border-white/30 pb-0.5">{row.category_label || '-'}</span>
</div>
),
className: "w-[35%] text-center"
},
{ 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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>Traffic Categories</span>
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="network-intelligence" />
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">Deep packet inspection analytics and geography.</p>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="network-intelligence" />
</div>
</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={false}
activeFilterCount={activeCount}
onReset={handleReset}
/>
<div className="grid gap-6 md:grid-cols-2">
{/* Left Column: App Categories List */}
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '100ms' }}>
<CardHeader className="border-b border-border/50 pb-4 mb-4">
<CardTitle className="text-lg text-white">App Categories List</CardTitle>
</CardHeader>
<CardContent>
<DataTable
data={processedData}
columns={appCols}
pageSize={50}
onRowClick={(row) => setSelectedCategory(row.category_label)}
/>
</CardContent>
</Card>
{/* Right Column: Graphs */}
<div className="flex flex-col gap-6">
{/* App Categories Graph */}
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '200ms' }}>
<CardHeader className="border-b border-border/50 pb-4 mb-4">
<CardTitle className="text-lg text-white">App Categories Chart</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={processedData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={2}
dataKey="download"
nameKey="category_label"
>
{processedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<RechartsTooltip
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
formatter={(val: any) => fmtBytes(val as number)}
/>
</PieChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Continents Graph */}
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: '300ms' }}>
<CardHeader className="border-b border-border/50 pb-4 mb-4">
<CardTitle className="text-lg text-white">Continents (Download Volume)</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={continents.data || []} layout="vertical" margin={{ top: 0, right: 30, left: 40, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#30363d" horizontal={true} vertical={false} />
<XAxis type="number" stroke="#8b949e" tickFormatter={(v) => `${(v/1000000000).toFixed(1)}GB`} />
<YAxis dataKey="continent_name" type="category" stroke="#8b949e" fontSize={12} />
<RechartsTooltip
cursor={{fill: '#21262d'}}
contentStyle={{ backgroundColor: '#161b22', borderColor: '#30363d', color: '#c9d1d9' }}
formatter={(val: any) => fmtBytes(val as number)}
/>
<Bar dataKey="download" fill="#58a6ff" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-4 border border-border rounded-md p-4 bg-secondary/30">
<p className="text-sm text-muted-foreground leading-relaxed">
Most of your traffic is bound for {continents.data && continents.data.length > 0 ? continents.data[0].continent_name : "unknown destinations"}. Ensure your firewall rules are optimized for latency across trans-oceanic cables.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
{selectedCategory && (
<CategoryDetailPanel
category={selectedCategory}
onClose={() => setSelectedCategory(null)}
/>
)}
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Loader2 } from "lucide-react";
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols, useCountries } from "@/lib/api-with-context";
import { GlobeMap } from "@/components/ui/GlobeMap";
import { getOriginLocation, getDestinationLocation } from "@/lib/geoUtils";
import { KPICards } from "@/components/dashboard/KPICards";
import { TopWidgets } from "@/components/dashboard/TopWidgets";
import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function SummaryPage() {
const [mounted, setMounted] = useState(false);
const summary = useDashboardSummary();
const timeline = useTimeline(24);
const rawTopApps = useTopApps(7);
const rawTopProtocols = useTopProtocols();
const countries = useCountries(0); // Load all countries for complete globemap (Requirement 16)
const { topProtocols, topApps } = useMemo(() => {
const rawProtos = rawTopProtocols.data || [];
let appsData = rawTopApps.data || [];
// Process Apps
let finalApps = appsData
.filter(a => a.app_label !== 'Unknown')
.map(a => ({
...a,
app_label: (a.app_label || '').replace(/siab\./g, 'backone.'),
total_bytes: (a.download || 0) + (a.upload || 0)
}))
.sort((a, b) => b.total_bytes - a.total_bytes)
.slice(0, 7);
// Process Protocols
let finalProtos = rawProtos
.map(p => ({
...p,
protocol_label: (p.protocol_label || '').replace(/siab\./g, 'backone.'),
total: (p.download || 0) + (p.upload || 0)
}))
.sort((a, b) => b.total - a.total)
.slice(0, 7);
return {
topProtocols: { ...rawTopProtocols, data: finalProtos },
topApps: { ...rawTopApps, data: finalApps.length > 0 ? finalApps : appsData }
};
}, [rawTopProtocols.data, rawTopApps.data, rawTopProtocols.isLoading, rawTopApps.isLoading]);
const mapData = useMemo(() => {
if (!countries.data) return [];
const origin = getOriginLocation();
return countries.data.reduce((acc: any[], row) => {
const dest = getDestinationLocation(row.country_code, row.country_name);
if (dest) {
acc.push({
startLat: origin.lat,
startLng: origin.lng,
endLat: dest.lat,
endLng: dest.lng,
fromLabel: origin.label,
toLabel: dest.label,
value: row.download + row.upload,
download: row.download,
upload: row.upload,
countryCode: row.country_code
});
}
return acc;
}, []);
}, [countries.data]);
useEffect(() => {
setMounted(true);
document.title = "Overview Dashboard | BackOne - Deep Package Inspection";
}, []);
if (!mounted) return null;
const isLoading = summary.isLoading || timeline.isLoading || topApps.isLoading || topProtocols.isLoading;
return (
<div className="space-y-8 animate-fade-in pb-10">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>Overview Dashboard</span>
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="overview" />
</div>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">Real-time network traffic and intelligence summary.</p>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="overview" />
</div>
</div>
{/* KPI Cards */}
<KPICards summary={summary.data} />
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7 animate-slide-up" style={{ animationDelay: '300ms' }}>
{/* Global Connections Map */}
<div className="col-span-1 lg:col-span-4 relative flex items-center justify-center w-full min-w-0">
<GlobeMap data={mapData} />
</div>
{/* Top Widgets (Apps & Protocols) */}
<TopWidgets topApps={topApps} topProtocols={topProtocols} />
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
// src/app/(dashboard)/security-audit/page.tsx
"use client";
import { useState, useEffect } from "react";
import { fetchSecurityDevices, SecurityDevice } from "@/lib/api";
import { useTlsVersions, useTlsCiphers, useTlsSecurity } from "@/lib/api-with-context";
import RiskSummaryCards from "@/components/security-audit/RiskSummaryCards";
import DeviceRiskTable from "@/components/security-audit/DeviceRiskTable";
import SecurityDistribution from "@/components/security-audit/SecurityDistribution";
import TlsVersionsChart from "@/components/security-audit/TlsVersionsChart";
import TlsTables from "@/components/security-audit/TlsTables";
import SslSanTable from "@/components/security-audit/SslSanTable";
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
import { HelpTrigger } from "@/components/help/HelpTrigger";
export default function SecurityAuditPage() {
const [mounted, setMounted] = useState(false);
const versions = useTlsVersions();
const ciphers = useTlsCiphers();
const security = useTlsSecurity();
const [secDevices, setSecDevices] = useState<SecurityDevice[]>([]);
const [secDevicesLoading, setSecDevicesLoading] = useState(true);
useEffect(() => {
setMounted(true);
fetchSecurityDevices()
.then(setSecDevices)
.catch(() => setSecDevices([]))
.finally(() => setSecDevicesLoading(false));
}, []);
const {
filters,
handleFilterChange,
handleReset, activeCount
} = useUniversalFilterState();
if (!mounted) return null;
const filterConfigs: FilterConfig[] = [
{ id: "risk_level", label: "Risk Level", type: "select", value: filters.risk_level, options: ["Vulnerable", "Moderate", "Safe"] },
];
const filteredDevices = secDevices.filter((d) => {
if (filters.risk_level && filters.risk_level !== "All" && d.risk_level !== filters.risk_level) return false;
return true;
});
// Risk summary counts
const vulnerableCount = filteredDevices.filter((d) => d.risk_level === "Vulnerable").length;
const moderateCount = filteredDevices.filter((d) => d.risk_level === "Moderate").length;
const safeCount = filteredDevices.filter((d) => d.risk_level === "Safe").length;
return (
<div className="space-y-6 pb-10">
<div className="flex flex-col gap-1">
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight truncate">Security & Encryption Audit</h2>
<p className="text-xs sm:text-sm text-slate-400 leading-normal sm:leading-relaxed">
Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles.
</p>
</div>
<UniversalFilters
filters={filterConfigs}
onChange={handleFilterChange}
showDateRange={false}
activeFilterCount={activeCount}
onReset={handleReset}
/>
{/* Risk Summary Cards */}
<RiskSummaryCards
vulnerableCount={vulnerableCount}
moderateCount={moderateCount}
safeCount={safeCount}
/>
{/* Charts Grid */}
<div className="grid gap-4 md:grid-cols-2">
<SecurityDistribution securityData={security.data} />
<TlsVersionsChart versionsData={versions.data} />
</div>
{/* TLS Versions and Ciphers Overview tables */}
<TlsTables
versionsData={versions.data}
ciphersData={ciphers.data}
/>
{/* SSL/TLS Subject Alternative Names Table */}
<SslSanTable />
{/* Device Risk Table */}
<DeviceRiskTable
secDevices={filteredDevices}
secDevicesLoading={secDevicesLoading}
/>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { Suspense } from "react";
import { Loader2 } from "lucide-react";
import ThreatsContent from "@/components/threats/ThreatsContent";
export default function ThreatsPage() {
return (
<Suspense
fallback={
<div className="flex items-center justify-center p-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
}
>
<ThreatsContent />
</Suspense>
);
}
@@ -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,39 @@
"use client";
import { Users, Plus } from "lucide-react";
interface EmptyStateProps {
role: string | null;
onAddUserClick: (companyName: string) => void;
}
export function EmptyState({ role, onAddUserClick }: EmptyStateProps) {
return (
<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">
<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={() => onAddUserClick("")}
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>
);
}
@@ -0,0 +1,167 @@
// 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, Lock, Unlock } from "lucide-react";
interface GetColumnsProps {
role: string | null;
currentUsername?: string | null;
viewAsLoading?: string | null;
unlockLoading?: string | null;
onEditClick: (user: ManagedUser) => void;
onViewAsClick?: (user: ManagedUser) => void;
onUnlockClick?: (user: ManagedUser) => void;
}
export function getColumns({
role,
currentUsername,
viewAsLoading,
unlockLoading,
onEditClick,
onViewAsClick,
onUnlockClick,
}: 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-[20%] text-center",
accessor: (row) => (
<span className="text-sm font-medium text-slate-200">
{row.account_name || row.username}
</span>
)
},
{
header: "Username",
className: "w-[16%] 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: "Status",
className: "w-[12%] text-center",
accessor: (row) => {
const isLocked = row.lockout_until && new Date(row.lockout_until) > new Date();
if (isLocked) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/30 uppercase tracking-wider">
<Lock className="w-3 h-3" /> Locked
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 uppercase tracking-wider">
Active
</span>
);
}
},
{
header: "Assigned Devices",
className: "w-[14%] 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";
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-[15%] text-center",
accessor: (row) => {
const isSelf = currentUsername && row.username === currentUsername;
const isLocked = row.lockout_until && new Date(row.lockout_until) > new Date();
const isDisabled = role === "EXECUTIVE" || isSelf;
const isUnlocking = unlockLoading === row.id;
return (
<div className="flex items-center justify-center gap-2">
{isLocked && onUnlockClick && (
<button
type="button"
onClick={() => onUnlockClick(row)}
disabled={isUnlocking}
title="Unlock account access"
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-semibold text-emerald-400 bg-emerald-400/10 border border-emerald-400/30 hover:bg-emerald-400/20 transition-all cursor-pointer disabled:opacity-50"
>
{isUnlocking ? <Loader2 className="w-3 h-3 animate-spin" /> : <Unlock className="w-3 h-3" />}
<span>Unlock</span>
</button>
)}
<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"}
</button>
</div>
);
}
}
];
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Plus } from "lucide-react";
import { getAdminUsers, getViewAsHeaders, startViewAsUser, unlockUser, 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";
import { EmptyState } from "./EmptyState";
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);
const [unlockLoading, setUnlockLoading] = useState<string | number | null>(null);
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();
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;
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) => {
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];
const agentLabel = agentRegistry[targetAgentUuid] || targetAgentUuid;
const userName = user.account_name || user.username;
const userRole = user.role;
setViewAsLoading(user.username);
try {
await startViewAsUser(targetAgentUuid, agentLabel, userName, userRole, user.id);
router.refresh();
window.location.reload();
} catch (err: any) {
alert('Failed to enter View As mode: ' + err.message);
} finally {
setViewAsLoading(null);
}
};
const handleUnlockUser = async (user: ManagedUser) => {
setUnlockLoading(user.id);
try {
const res = await unlockUser(user.id);
if (res.ok) {
await loadData();
} else {
alert('Failed to unlock user: ' + (res.error || 'Unknown error'));
}
} catch (err: any) {
alert('Failed to unlock user: ' + err.message);
} finally {
setUnlockLoading(null);
}
};
const columns = getColumns({
role,
currentUsername,
viewAsLoading,
unlockLoading: unlockLoading !== null ? String(unlockLoading) : null,
onEditClick: handleEditClick,
onViewAsClick: (role === "SUPER_ADMIN" || role === "COMPANY_ADMIN") ? handleViewAsUser : undefined,
onUnlockClick: (role === "SUPER_ADMIN" || role === "COMPANY_ADMIN" || role === "TENANT_ADMIN") ? handleUnlockUser : undefined,
});
return (
<div className="space-y-6 pb-24 sm:pb-10 animate-fade-in">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
<span>User Accounts Directory</span>
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-amber-500 flex-shrink-0" />}
</h2>
{/* Mobile Actions: Inline next to Title */}
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
<HelpTrigger pageId="user-accounts" />
</div>
</div>
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">
Manage company tenant user access, roles, and device ownership delegation.
</p>
</div>
{/* Laptop/Tablet Actions: Floats on the right */}
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
<HelpTrigger pageId="user-accounts" />
</div>
</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 ? (
<EmptyState role={role} onAddUserClick={handleAddUserClick} />
) : (
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 pb-6 sm:pb-0">
<button
type="button"
onClick={() => handleAddUserClick("")}
className="w-full sm:w-auto flex items-center justify-center gap-2 px-5 py-3 text-xs font-semibold bg-amber-600 hover:bg-amber-700 text-white rounded-xl transition-all shadow-lg shadow-amber-600/20 cursor-pointer"
>
<Plus className="w-4 h-4" />
Add New Company Admin
</button>
</div>
)}
</div>
)}
<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>
);
}