v1.1: Add CIDR subnet filtering and Agent filter in Devices page
This commit is contained in:
commit
966058e2fe
422 files changed
+54656
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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)`
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(req: NextRequest, props: { params: Promise<{ route: string[] }> }) {
|
||||
return handleProxy(req, props);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, props: { params: Promise<{ route: string[] }> }) {
|
||||
return handleProxy(req, props);
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, props: { params: Promise<{ route: string[] }> }) {
|
||||
return handleProxy(req, props);
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, props: { params: Promise<{ route: string[] }> }) {
|
||||
return handleProxy(req, props);
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest, props: { params: Promise<{ route: string[] }> }) {
|
||||
return handleProxy(req, props);
|
||||
}
|
||||
|
||||
async function handleProxy(req: NextRequest, { params }: { params: Promise<{ route: string[] }> }) {
|
||||
try {
|
||||
const routeArray = (await params).route;
|
||||
const route = routeArray.join('/');
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Force ALL traffic to go through our own Centralized Proxy Server (Express Backend)
|
||||
const localBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001';
|
||||
const destinationUrl = `${localBase}/api/${route}${url.search}`;
|
||||
|
||||
// Prepare headers
|
||||
const headers = new Headers();
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType) {
|
||||
headers.set('Content-Type', contentType);
|
||||
} else {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const contentLength = req.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
// Forward cookie for authentication
|
||||
const cookie = req.headers.get('cookie');
|
||||
if (cookie) {
|
||||
headers.set('cookie', cookie);
|
||||
}
|
||||
|
||||
// Forward all custom headers starting with x- (e.g. x-backone-site-uuid, x-view-as-agent)
|
||||
req.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase().startsWith('x-')) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const hasBody = !['GET', 'HEAD'].includes(req.method);
|
||||
const body = hasBody ? req.body : undefined;
|
||||
|
||||
const response = await fetch(destinationUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: body,
|
||||
cache: 'no-store', // Prevent Next.js from aggressively caching the backend response
|
||||
// @ts-ignore
|
||||
duplex: hasBody ? 'half' : undefined,
|
||||
});
|
||||
|
||||
const responseBuffer = await response.arrayBuffer();
|
||||
|
||||
const nextResponse = new NextResponse(responseBuffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const rawCookies = response.headers.getSetCookie ? response.headers.getSetCookie() : [];
|
||||
if (rawCookies.length > 0) {
|
||||
rawCookies.forEach(cookie => {
|
||||
nextResponse.headers.append('Set-Cookie', cookie);
|
||||
});
|
||||
} else {
|
||||
const setCookieHeader = response.headers.get('set-cookie');
|
||||
if (setCookieHeader) {
|
||||
nextResponse.headers.set('Set-Cookie', setCookieHeader);
|
||||
}
|
||||
}
|
||||
|
||||
return nextResponse;
|
||||
} catch (error: any) {
|
||||
console.error('[Proxy Error]', error);
|
||||
return NextResponse.json({ ok: false, message: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3011";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
// Menerima JSON dari frontend (bukan multipart)
|
||||
const body = await req.json();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (token) {
|
||||
headers.set("Cookie", `token=${token}`);
|
||||
}
|
||||
req.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase().startsWith("x-")) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const destinationUrl = `${BACKEND_URL}/api/auth/admin/create-external-user`;
|
||||
|
||||
const response = await fetch(destinationUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const responseBuffer = await response.arrayBuffer();
|
||||
return new NextResponse(responseBuffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[Create External User Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Failed to connect to the server." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3011";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
// Baca body sebagai JSON (tidak ada multipart lagi)
|
||||
const body = await req.json();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (token) {
|
||||
headers.set("Cookie", `token=${token}`);
|
||||
}
|
||||
req.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase().startsWith("x-")) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const destinationUrl = `${BACKEND_URL}/api/auth/admin/update-agent-user`;
|
||||
|
||||
const response = await fetch(destinationUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const responseBuffer = await response.arrayBuffer();
|
||||
return new NextResponse(responseBuffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[Update Agent User Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Failed to connect to the server." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// src/app/api/auth/remove-profile-picture/route.ts
|
||||
// Next.js API Route — proxies remove-profile-picture ke backend Express.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3001";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
const headers: HeadersInit = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Cookie"] = `token=${token}`;
|
||||
}
|
||||
|
||||
const backendRes = await fetch(
|
||||
`${BACKEND_URL}/api/auth/remove-profile-picture`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
const contentType = backendRes.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
const text = await backendRes.text();
|
||||
console.error("[Remove-Picture Proxy] Backend returned non-JSON:", text.slice(0, 200));
|
||||
return NextResponse.json(
|
||||
{ error: `Server error (HTTP ${backendRes.status}): Please try again.` },
|
||||
{ status: backendRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await backendRes.json();
|
||||
const response = NextResponse.json(data, { status: backendRes.status });
|
||||
|
||||
const setCookie = backendRes.headers.get("set-cookie");
|
||||
if (setCookie) {
|
||||
response.headers.set("set-cookie", setCookie);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
console.error("[Remove-Picture Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to connect to the server." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3011";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
// Menerima JSON (base64 image) dari frontend
|
||||
const body = await req.json();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (token) {
|
||||
headers.set("Cookie", `token=${token}`);
|
||||
}
|
||||
req.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase().startsWith("x-")) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const destinationUrl = `${BACKEND_URL}/api/auth/upload-profile-picture`;
|
||||
|
||||
const response = await fetch(destinationUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const responseBuffer = await response.arrayBuffer();
|
||||
return new NextResponse(responseBuffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[Upload Profile Picture Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to connect to the server." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,177 @@
|
||||
@import "tailwindcss";
|
||||
@import "./theme.css";
|
||||
@import "./variables.css";
|
||||
|
||||
/* ── Zero Horizontal Scrollbar – Global Enforcement ── */
|
||||
/* Per AGENTS.md §9: horizontal scrollbar strictly prohibited at any level. */
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Auto-Hide Floating Help FAB when any Pop-Up Modal is active ── */
|
||||
body[style*="overflow: hidden"] .help-center-fab,
|
||||
body[style*="overflow:hidden"] .help-center-fab,
|
||||
body:has([role="dialog"]) .help-center-fab,
|
||||
body:has(.animate-modal-in) .help-center-fab {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Times New Roman', Times, serif;
|
||||
color: var(--foreground);
|
||||
/* Optical sizing + smoothing */
|
||||
font-optical-sizing: auto;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* 1-Second Dual-Layer Smooth Background Crossfade */
|
||||
|
||||
/* Dark Mode Background Layer */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
background-color: #09090b;
|
||||
background-image:
|
||||
radial-gradient(circle at 0% 0%, rgba(30, 58, 138, 0.35) 0%, transparent 40%),
|
||||
radial-gradient(circle at 100% 20%, rgba(220, 38, 38, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(circle at 50% 100%, rgba(15, 23, 42, 0.8) 0%, transparent 60%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ambient Colors for Cards & Sidebars */
|
||||
:root {
|
||||
--ambient-card-bg: rgba(24, 24, 27, 0.7);
|
||||
--ambient-sidebar-bg: rgba(9, 9, 11, 0.95);
|
||||
}
|
||||
|
||||
.bg-card {
|
||||
background-color: var(--ambient-card-bg) !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.bg-sidebar {
|
||||
background-color: var(--ambient-sidebar-bg) !important;
|
||||
background-image: none !important;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Global smooth transition for theme change */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, box-shadow, opacity, transform !important;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
transition-duration: 500ms !important;
|
||||
}
|
||||
|
||||
canvas {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* View Transitions API */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 500ms;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
/* ── Custom Scrollbar for Fixed Modals ── */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ── Modal Animations (0.5s) ── */
|
||||
@keyframes modalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modalFadeOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes overlayFadeOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-modal-in {
|
||||
animation: modalFadeIn 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-modal-out {
|
||||
animation: modalFadeOut 500ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
.animate-overlay-in {
|
||||
animation: overlayFadeIn 500ms ease-out forwards;
|
||||
}
|
||||
|
||||
.animate-overlay-out {
|
||||
animation: overlayFadeOut 500ms ease-in forwards;
|
||||
}
|
||||
|
||||
/* Hide native Edge/IE password reveal icon (the second eye) */
|
||||
input::-ms-reveal,
|
||||
input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Accordion Animation Fix */
|
||||
.accordion-grid {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 1s ease-in-out, margin-top 1s ease-in-out, opacity 1s ease-in-out;
|
||||
opacity: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.accordion-grid.expanded {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.accordion-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import HelpPageContent from "@/components/help/HelpPageContent";
|
||||
|
||||
export const metadata = {
|
||||
title: "User Guide — Deep Package Inspection",
|
||||
description: "Comprehensive guide on reading data across every DPI dashboard page.",
|
||||
};
|
||||
|
||||
export default function HelpRoute() {
|
||||
return <HelpPageContent />;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 429 KiB |
@@ -0,0 +1,65 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import "./globals.css";
|
||||
|
||||
// Inter font — local variable font file, matches demoplace.my.id
|
||||
// demoplace uses: --font-sans: var(--font-inter) which renders as Inter
|
||||
const inter = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../public/fonts/Inter-Variable.woff2",
|
||||
weight: "100 900",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
adjustFontFallback: false,
|
||||
});
|
||||
|
||||
const backoneFont = localFont({
|
||||
src: "../../public/fonts/BackOneLogo-Regular.ttf",
|
||||
variable: "--font-backone",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "BackOne - Deep Package Inspection",
|
||||
description: "BackOne by PT. Data Bisnis Solusi - Network Intelligence Dashboard",
|
||||
icons: {
|
||||
icon: "/backone-logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${inter.variable} ${backoneFont.variable} h-full antialiased dark`}
|
||||
style={{ colorScheme: "dark" }}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col overflow-x-hidden bg-background text-foreground" suppressHydrationWarning>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
forcedTheme="dark"
|
||||
enableSystem={false}
|
||||
>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lock, User, Eye, EyeOff, ShieldAlert, Timer } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [lockoutSeconds, setLockoutSeconds] = useState<number | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (lockoutSeconds === null || lockoutSeconds <= 0) return;
|
||||
const interval = setInterval(() => {
|
||||
setLockoutSeconds((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(interval);
|
||||
setError("");
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [lockoutSeconds]);
|
||||
|
||||
const formatSeconds = (sec: number) => {
|
||||
const mins = Math.floor(sec / 60);
|
||||
const secs = sec % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (lockoutSeconds !== null && lockoutSeconds > 0) return;
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
let data: any = {};
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
data = await res.json();
|
||||
} else {
|
||||
const text = await res.text();
|
||||
data = { error: text || `Server error (${res.status})` };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.lockout_seconds) {
|
||||
setLockoutSeconds(data.lockout_seconds);
|
||||
}
|
||||
throw new Error(data.error || data.message || "Login failed");
|
||||
}
|
||||
|
||||
// Selalu bersihkan state view-as lama saat login akun baru
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("backone_view_as");
|
||||
}
|
||||
|
||||
window.location.href = "/";
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isLockedOut = lockoutSeconds !== null && lockoutSeconds > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col justify-center items-center relative overflow-hidden">
|
||||
{/* Background Glows */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-red-600/20 blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-700/20 blur-[120px] pointer-events-none" />
|
||||
|
||||
<div className="z-10 w-full max-w-md p-8 bg-sidebar/80 backdrop-blur-2xl rounded-2xl border border-border/50 shadow-2xl relative">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<img src="/backone-logo.png" alt="BackOne" className="w-16 h-16 drop-shadow-[0_0_15px_rgba(220,38,38,0.5)] mb-4" />
|
||||
<h1 className="text-2xl text-center font-bold font-backone text-white tracking-normal leading-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||
BackOne Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/40 text-red-400 text-xs sm:text-sm space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 font-medium leading-relaxed">{error}</div>
|
||||
</div>
|
||||
{isLockedOut && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2 border-t border-red-500/20 font-mono font-bold text-red-300 text-base">
|
||||
<Timer className="w-4 h-4 animate-pulse" />
|
||||
<span>Unlock in {formatSeconds(lockoutSeconds)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
disabled={isLockedOut || loading}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all disabled:opacity-50"
|
||||
placeholder="Username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
disabled={isLockedOut || loading}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all disabled:opacity-50"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLockedOut || loading}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-white transition-colors focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || isLockedOut}
|
||||
className="w-full py-2.5 px-4 bg-gradient-to-r from-red-600 to-blue-700 hover:from-red-500 hover:to-blue-600 text-white font-medium rounded-lg shadow-[0_0_15px_rgba(220,38,38,0.3)] hover:shadow-[0_0_20px_rgba(220,38,38,0.5)] transition-all duration-300 flex items-center justify-center mt-6 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : isLockedOut ? (
|
||||
`Locked (${formatSeconds(lockoutSeconds)})`
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--font-backone: var(--font-backone);
|
||||
|
||||
/* Set Inter/sans-serif as the default globally */
|
||||
--font-sans: var(--font-inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
|
||||
|
||||
|
||||
/* Mono also uses Inter first — matches demoplace (--default-mono-font-family: var(--font-inter)) */
|
||||
--font-mono: var(--font-inter, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-sidebar: var(--sidebar);
|
||||
|
||||
/* OVERRIDE TAILWIND HARDCODED COLORS FOR SEAMLESS LIGHT MODE */
|
||||
--color-slate-900: var(--slate-900);
|
||||
--color-slate-800: var(--slate-800);
|
||||
--color-slate-700: var(--slate-700);
|
||||
--color-slate-600: var(--slate-600);
|
||||
--color-slate-500: var(--slate-500);
|
||||
--color-slate-400: var(--slate-400);
|
||||
--color-slate-300: var(--slate-300);
|
||||
--color-slate-200: var(--slate-200);
|
||||
--color-slate-100: var(--slate-100);
|
||||
--color-slate-50: var(--slate-50);
|
||||
|
||||
--color-gray-900: var(--slate-900);
|
||||
--color-gray-800: var(--slate-800);
|
||||
--color-gray-700: var(--slate-700);
|
||||
--color-gray-600: var(--slate-600);
|
||||
--color-gray-500: var(--slate-500);
|
||||
--color-gray-400: var(--slate-400);
|
||||
--color-gray-300: var(--slate-300);
|
||||
--color-gray-200: var(--slate-200);
|
||||
--color-gray-100: var(--slate-100);
|
||||
--color-gray-50: var(--slate-50);
|
||||
|
||||
--color-white: var(--t-white);
|
||||
--color-black: var(--t-black);
|
||||
|
||||
/* Radix-style color palette — matching demoplace.my.id exactly */
|
||||
--color-red-50: #fef2f2;
|
||||
--color-red-100: #ffe2e2;
|
||||
--color-red-200: #ffcaca;
|
||||
--color-red-300: #ffa3a3;
|
||||
--color-red-400: #ff6568;
|
||||
--color-red-500: #fb2c36;
|
||||
--color-red-600: #e40014;
|
||||
--color-red-700: #bf000f;
|
||||
--color-red-800: #9f0712;
|
||||
--color-red-900: #82181a;
|
||||
--color-red-950: #460809;
|
||||
|
||||
--color-orange-300: #ffb96d;
|
||||
--color-orange-400: #ff8b1a;
|
||||
--color-orange-500: #fe6e00;
|
||||
--color-orange-900: #7e2a0c;
|
||||
--color-orange-950: #441306;
|
||||
|
||||
--color-amber-100: #fef3c6;
|
||||
--color-amber-200: #fee685;
|
||||
--color-amber-300: #ffd236;
|
||||
--color-amber-400: #fcbb00;
|
||||
--color-amber-500: #f99c00;
|
||||
--color-amber-600: #dd7400;
|
||||
--color-amber-700: #b75000;
|
||||
--color-amber-800: #953d00;
|
||||
--color-amber-900: #7b3306;
|
||||
--color-amber-950: #461901;
|
||||
|
||||
--color-yellow-300: #ffe02a;
|
||||
--color-yellow-400: #fac800;
|
||||
--color-yellow-500: #edb200;
|
||||
|
||||
--color-green-300: #7bf1a8;
|
||||
--color-green-400: #05df72;
|
||||
--color-green-500: #00c758;
|
||||
--color-green-800: #016630;
|
||||
--color-green-900: #0d542b;
|
||||
--color-green-950: #032e15;
|
||||
|
||||
--color-emerald-100: #d0fae5;
|
||||
--color-emerald-200: #a4f4cf;
|
||||
--color-emerald-300: #5ee9b5;
|
||||
--color-emerald-400: #00d294;
|
||||
--color-emerald-500: #00bb7f;
|
||||
--color-emerald-600: #009767;
|
||||
--color-emerald-700: #007956;
|
||||
--color-emerald-900: #004e3b;
|
||||
--color-emerald-950: #002c22;
|
||||
|
||||
--color-teal-400: #00d3bd;
|
||||
--color-teal-500: #00baa7;
|
||||
|
||||
--color-cyan-100: #cefafe;
|
||||
--color-cyan-300: #53eafd;
|
||||
--color-cyan-400: #00d2ef;
|
||||
--color-cyan-500: #00b7d7;
|
||||
--color-cyan-600: #0092b5;
|
||||
|
||||
--color-sky-400: #00bcfe;
|
||||
|
||||
--color-blue-50: #eff6ff;
|
||||
--color-blue-200: #bedbff;
|
||||
--color-blue-300: #90c5ff;
|
||||
--color-blue-400: #54a2ff;
|
||||
--color-blue-500: #3080ff;
|
||||
--color-blue-600: #155dfc;
|
||||
--color-blue-700: #1447e6;
|
||||
--color-blue-800: #193cb8;
|
||||
--color-blue-900: #1c398e;
|
||||
--color-blue-950: #162456;
|
||||
|
||||
--color-indigo-300: #a4b3ff;
|
||||
--color-indigo-400: #7d87ff;
|
||||
--color-indigo-500: #625fff;
|
||||
--color-indigo-600: #4f39f6;
|
||||
--color-indigo-900: #312c85;
|
||||
|
||||
--color-violet-400: #a685ff;
|
||||
--color-violet-500: #8d54ff;
|
||||
|
||||
--color-purple-300: #d9b3ff;
|
||||
--color-purple-400: #c07eff;
|
||||
--color-purple-500: #ac4bff;
|
||||
--color-purple-600: #9810fa;
|
||||
--color-purple-900: #59168b;
|
||||
--color-purple-950: #3c0366;
|
||||
|
||||
--color-pink-400: #fb64b6;
|
||||
--color-rose-400: #ff667f;
|
||||
--color-rose-900: #8b0836;
|
||||
|
||||
/* Micro-animations */
|
||||
--animate-fade-in: fade-in 0.5s ease-out forwards;
|
||||
--animate-slide-up: slide-up 0.5s ease-out forwards;
|
||||
|
||||
@keyframes fade-in {
|
||||
0% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
0% { opacity: 0; transform: translateY(20px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Premium Dark Theme */
|
||||
--background: #09090b;
|
||||
--foreground: #fafafa;
|
||||
|
||||
--card: rgba(24, 24, 27, 0.7);
|
||||
--card-foreground: #fafafa;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--primary: #3b82f6;
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(39, 39, 42, 0.8);
|
||||
--secondary-foreground: #fafafa;
|
||||
|
||||
--muted: rgba(39, 39, 42, 0.5);
|
||||
--muted-foreground: #a1a1aa;
|
||||
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--sidebar: #09090b;
|
||||
|
||||
/* Standardized Dark Theme Colors */
|
||||
--t-white: #ffffff;
|
||||
--t-black: #000000;
|
||||
--slate-900: #0f172a;
|
||||
--slate-800: #1e293b;
|
||||
--slate-700: #334155;
|
||||
--slate-600: #475569;
|
||||
--slate-500: #64748b;
|
||||
--slate-400: #94a3b8;
|
||||
--slate-300: #cbd5e1;
|
||||
--slate-200: #e2e8f0;
|
||||
--slate-100: #f1f5f9;
|
||||
--slate-50: #f8fafc;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { RefObject } from "react";
|
||||
import { User, Key, Tag, Cpu, Upload } from "lucide-react";
|
||||
|
||||
interface AgentAccountFormFieldsProps {
|
||||
isEdit: boolean;
|
||||
username: string;
|
||||
setUsername: (val: string) => void;
|
||||
password: string;
|
||||
setPassword: (val: string) => void;
|
||||
accountName: string;
|
||||
setAccountName: (val: string) => void;
|
||||
agentUuid: string;
|
||||
previewPic: string | null;
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
handlePicChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function AgentAccountFormFields({
|
||||
isEdit,
|
||||
username,
|
||||
setUsername,
|
||||
password,
|
||||
setPassword,
|
||||
accountName,
|
||||
setAccountName,
|
||||
agentUuid,
|
||||
previewPic,
|
||||
fileInputRef,
|
||||
handlePicChange,
|
||||
}: AgentAccountFormFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full border-2 border-dashed border-slate-600 flex items-center justify-center cursor-pointer hover:border-blue-500 transition-colors overflow-hidden bg-slate-800"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{previewPic ? (
|
||||
<img src={previewPic} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">Profile Picture</p>
|
||||
<p className="text-xs text-slate-500">Click to upload image</p>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePicChange} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Name (Label Agent)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={e => setAccountName(e.target.value)}
|
||||
placeholder="e.g., Gateway 007"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">This name will appear as a label in the Agents tab</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Cpu className="w-3.5 h-3.5 inline mr-1" />
|
||||
Agent UUID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentUuid}
|
||||
readOnly
|
||||
className="w-full px-3 py-2.5 bg-slate-800/50 border border-slate-700 rounded-lg text-slate-400 font-mono text-sm cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Login Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Username for login"
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
Password {isEdit && <span className="text-slate-500">(leave blank to keep unchanged)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "••••••••" : "New password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, Trash2, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { createAdminAgentUser, updateAdminAgentUser, deleteAdminAgentUser } from "@/lib/admin-api";
|
||||
import { AgentAccountFormFields } from "./AgentAccountFormFields";
|
||||
|
||||
interface AgentAccountModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
agentUuid: string;
|
||||
agentLabel: string;
|
||||
existingUser: ManagedUser | null;
|
||||
}
|
||||
|
||||
export default function AgentAccountModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
agentUuid,
|
||||
agentLabel,
|
||||
existingUser,
|
||||
}: AgentAccountModalProps) {
|
||||
const isEdit = !!existingUser;
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [accountName, setAccountName] = useState("");
|
||||
const [previewPic, setPreviewPic] = useState<string | null>(null);
|
||||
const [picFile, setPicFile] = useState<File | null>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setPicFile(null);
|
||||
if (existingUser) {
|
||||
setUsername(existingUser.username);
|
||||
setAccountName(existingUser.account_name || "");
|
||||
setPassword("");
|
||||
setPreviewPic(
|
||||
existingUser.profile_picture
|
||||
? `/api/auth/uploads/${existingUser.profile_picture}`
|
||||
: null
|
||||
);
|
||||
} else {
|
||||
setUsername("");
|
||||
setAccountName(agentLabel !== agentUuid ? agentLabel : "");
|
||||
setPassword("");
|
||||
setPreviewPic(null);
|
||||
}
|
||||
}, [isOpen, existingUser, agentUuid, agentLabel]);
|
||||
|
||||
const handlePicChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setPicFile(file);
|
||||
setPreviewPic(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
const payload: Record<string, any> = {
|
||||
user_id: String(existingUser!.id),
|
||||
account_name: accountName.trim(),
|
||||
agent_uuid: agentUuid,
|
||||
};
|
||||
if (username.trim()) payload.username = username.trim();
|
||||
if (password) payload.password = password;
|
||||
|
||||
await updateAdminAgentUser(payload);
|
||||
|
||||
if (picFile) {
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(picFile);
|
||||
});
|
||||
await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_picture_base64: base64, user_id: String(existingUser!.id) }),
|
||||
});
|
||||
}
|
||||
|
||||
setSuccess("Account successfully updated!");
|
||||
} else {
|
||||
await createAdminAgentUser({
|
||||
username: username.trim(),
|
||||
password,
|
||||
account_name: accountName.trim() || undefined,
|
||||
agent_uuid: agentUuid,
|
||||
});
|
||||
setSuccess("Network Agent account successfully created!");
|
||||
}
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1200);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!existingUser) return;
|
||||
if (!confirm(`Delete account "${existingUser.username}"? This action cannot be undone.`)) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAdminAgentUser(existingUser.id);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-blue-900/40 to-slate-900">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{isEdit ? "Edit Agent Account" : "Create New Agent Account"}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5 font-mono">{agentUuid}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
<AgentAccountFormFields
|
||||
isEdit={isEdit}
|
||||
username={username}
|
||||
setUsername={setUsername}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
accountName={accountName}
|
||||
setAccountName={setAccountName}
|
||||
agentUuid={agentUuid}
|
||||
previewPic={previewPic}
|
||||
fileInputRef={fileInputRef}
|
||||
handlePicChange={handlePicChange}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-900/30 border border-green-800 text-green-300 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm hover:bg-red-900/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border border-slate-600 text-slate-300 text-sm hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{isEdit ? "Save Changes" : "Create Account"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
interface AgentChecklistProps {
|
||||
availableAgents: string[];
|
||||
selectedAgents: string[];
|
||||
onAgentCheck: (uuid: string, checked: boolean) => void;
|
||||
agentNamesMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export default function AgentChecklist({
|
||||
availableAgents,
|
||||
selectedAgents,
|
||||
onAgentCheck,
|
||||
agentNamesMap,
|
||||
}: AgentChecklistProps) {
|
||||
const filteredAgents = availableAgents
|
||||
.filter(Boolean)
|
||||
.filter((uuid) => uuid.trim() !== "");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-2">
|
||||
🎯 Assign Network Agents ({selectedAgents.length} selected)
|
||||
</label>
|
||||
<div className="w-full max-h-40 overflow-y-auto bg-slate-800/40 border border-slate-700 rounded-lg p-3 space-y-2.5 custom-scrollbar">
|
||||
{filteredAgents.length === 0 ? (
|
||||
<p className="text-xs text-slate-500 italic">No network agents available</p>
|
||||
) : (
|
||||
filteredAgents.map((uuid) => {
|
||||
const isChecked = selectedAgents.includes(uuid);
|
||||
const agentName = agentNamesMap[uuid] || uuid;
|
||||
return (
|
||||
<label
|
||||
key={uuid}
|
||||
className="flex items-center gap-3 cursor-pointer group text-xs text-slate-300 select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => onAgentCheck(uuid, e.target.checked)}
|
||||
className="rounded border-slate-600 bg-slate-800 text-amber-500 focus:ring-amber-500/50 w-4 h-4 cursor-pointer"
|
||||
/>
|
||||
<span className="group-hover:text-white transition-colors">
|
||||
{agentName}
|
||||
</span>
|
||||
{agentName !== uuid && (
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
({uuid})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { MapPin } from "lucide-react";
|
||||
|
||||
export function EmptyMap() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-72 gap-4">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
style={{ background: "rgba(99,102,241,0.1)", border: "1px solid rgba(99,102,241,0.25)" }}>
|
||||
<MapPin className="w-7 h-7 text-indigo-400 opacity-60" />
|
||||
</div>
|
||||
<div className="text-center font-serif">
|
||||
<p className="text-sm font-semibold text-slate-300">No agent locations configured</p>
|
||||
<p className="text-xs text-slate-400 mt-2.5 max-w-sm leading-relaxed flex items-center justify-center gap-1.5 flex-wrap">
|
||||
<span>Click the</span>
|
||||
<span className="inline-flex items-center justify-center p-1 rounded bg-indigo-950/20 text-slate-500 border border-slate-700/30" title="Map Pin Icon">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-500" />
|
||||
</span>
|
||||
<span>icon in the</span>
|
||||
<span className="text-slate-300 font-semibold">Actions</span>
|
||||
<span>column to set coordinates.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
interface AgentLocationFormFieldsProps {
|
||||
latitude: string;
|
||||
setLatitude: (val: string) => void;
|
||||
longitude: string;
|
||||
setLongitude: (val: string) => void;
|
||||
label: string;
|
||||
setLabel: (val: string) => void;
|
||||
}
|
||||
|
||||
export function AgentLocationFormFields({
|
||||
latitude,
|
||||
setLatitude,
|
||||
longitude,
|
||||
setLongitude,
|
||||
label,
|
||||
setLabel,
|
||||
}: AgentLocationFormFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Latitude</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={latitude}
|
||||
onChange={(e) => setLatitude(e.target.value)}
|
||||
placeholder="e.g. -6.2263304"
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Longitude</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={longitude}
|
||||
onChange={(e) => setLongitude(e.target.value)}
|
||||
placeholder="e.g. 106.4247322"
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Location Label (Optional)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Balaraja Office HQ"
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// src/components/admin/AgentLocationMap.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import {
|
||||
EmptyMap,
|
||||
MAP_CSS,
|
||||
drawFlowLines,
|
||||
drawMarkers,
|
||||
type AgentLocationPin
|
||||
} from "./AgentLocationMapHelpers";
|
||||
|
||||
interface Props {
|
||||
pins: AgentLocationPin[];
|
||||
onEditLocation?: (agentUuid: string, agentLabel: string) => void;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
const AgentLocationMap = React.memo(function AgentLocationMap({
|
||||
pins,
|
||||
onEditLocation,
|
||||
canEdit = false,
|
||||
}: Props) {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const instanceRef = useRef<any>(null);
|
||||
const LRef = useRef<any>(null);
|
||||
const [flows, setFlows] = React.useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetch("/api/dashboard/agent-flows", { headers: getViewAsHeaders() })
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
if (active && json.ok && json.data) {
|
||||
setFlows(json.data);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("Failed to load agent flows for map:", err));
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [pins]);
|
||||
|
||||
const resetView = () => {
|
||||
if (!instanceRef.current || !pins.length || !LRef.current) return;
|
||||
const L = LRef.current;
|
||||
if (pins.length === 1) {
|
||||
instanceRef.current.setView([pins[0].latitude, pins[0].longitude], 14, { animate: false });
|
||||
} else {
|
||||
instanceRef.current.fitBounds(L.latLngBounds(pins.map((p) => [p.latitude, p.longitude])), {
|
||||
padding: [70, 70],
|
||||
maxZoom: 14,
|
||||
animate: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
if (!mapRef.current) return;
|
||||
|
||||
// Inject styles
|
||||
const styleId = "leaflet-custom-markers-css";
|
||||
if (!document.getElementById(styleId)) {
|
||||
const s = document.createElement("style");
|
||||
s.id = styleId;
|
||||
s.innerHTML = MAP_CSS;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
const boot = async () => {
|
||||
if (!mounted || !mapRef.current) return;
|
||||
|
||||
// Inject Leaflet CSS
|
||||
if (!document.getElementById("lf-css-admin")) {
|
||||
const lnk = document.createElement("link");
|
||||
lnk.id = "lf-css-admin";
|
||||
lnk.rel = "stylesheet";
|
||||
lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(lnk);
|
||||
}
|
||||
|
||||
const L = (await import("leaflet")).default;
|
||||
if (!mounted || !mapRef.current) return;
|
||||
LRef.current = L;
|
||||
|
||||
if (instanceRef.current) {
|
||||
try {
|
||||
instanceRef.current.remove();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const map = L.map(mapRef.current, {
|
||||
center: pins[0] ? [pins[0].latitude, pins[0].longitude] : [-2.548926, 118.0148634],
|
||||
zoom: pins.length === 1 ? 14 : 8,
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
minZoom: 2,
|
||||
maxZoom: 20,
|
||||
maxBounds: [[-90, -180], [90, 180]],
|
||||
maxBoundsViscosity: 1.0,
|
||||
zoomAnimation: false,
|
||||
fadeAnimation: false,
|
||||
markerZoomAnimation: false,
|
||||
});
|
||||
instanceRef.current = map;
|
||||
|
||||
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
|
||||
subdomains: "abcd",
|
||||
maxZoom: 20,
|
||||
noWrap: true,
|
||||
}).addTo(map);
|
||||
|
||||
if (pins.length > 1) {
|
||||
map.fitBounds(L.latLngBounds(pins.map((p) => [p.latitude, p.longitude])), {
|
||||
padding: [70, 70],
|
||||
maxZoom: 14,
|
||||
animate: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Get heartbeats for agent uptime from window or fetch ───────────────
|
||||
const uptimeMap: Record<string, number> = {};
|
||||
try {
|
||||
const res = await fetch("/api/dashboard/agent-heartbeats");
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.ok && json.data) {
|
||||
json.data.forEach((item: any) => {
|
||||
uptimeMap[item.agent_uuid] = item.uptime_percent ?? 100;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Draw Markers
|
||||
drawMarkers(map, L, pins, uptimeMap, canEdit, onEditLocation);
|
||||
};
|
||||
|
||||
boot();
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (instanceRef.current) {
|
||||
try {
|
||||
instanceRef.current.remove();
|
||||
} catch (_) {}
|
||||
instanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [pins, flows, canEdit]);
|
||||
|
||||
if (!pins.length) return <EmptyMap />;
|
||||
|
||||
return (
|
||||
<div className="relative w-full rounded-xl overflow-hidden" style={{ height: 420 }}>
|
||||
<div ref={mapRef} style={{ width: "100%", height: "100%", background: "#0a1628" }} />
|
||||
|
||||
{/* Controls */}
|
||||
<div className="absolute top-3 right-3 z-[1000] flex flex-col gap-1.5">
|
||||
{[
|
||||
{ t: "Zoom In", l: "+", fn: () => instanceRef.current?.zoomIn() },
|
||||
{ t: "Zoom Out", l: "−", fn: () => instanceRef.current?.zoomOut() },
|
||||
].map((b) => (
|
||||
<button
|
||||
key={b.t}
|
||||
onClick={b.fn}
|
||||
title={b.t}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg font-bold text-xl text-white hover:bg-indigo-600/30 transition-colors"
|
||||
style={{
|
||||
background: "rgba(13,21,38,0.92)",
|
||||
border: "1px solid rgba(99,102,241,0.35)",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
{b.l}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={resetView}
|
||||
title="Fit all agents"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-lg text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
style={{
|
||||
background: "rgba(13,21,38,0.92)",
|
||||
border: "1px solid rgba(99,102,241,0.35)",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className="absolute bottom-3 left-3 z-[1000] flex gap-4 text-xs px-3.5 py-2 rounded-xl"
|
||||
style={{
|
||||
background: "rgba(10,17,32,0.88)",
|
||||
border: "1px solid rgba(99,102,241,0.2)",
|
||||
backdropFilter: "blur(8px)",
|
||||
}}
|
||||
>
|
||||
{[
|
||||
["#10b981", "Online", true],
|
||||
["#ef4444", "Offline", false],
|
||||
].map(([c, l, glow]: any) => (
|
||||
<span key={l} className="flex items-center gap-1.5 text-slate-400">
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: "50%",
|
||||
background: c,
|
||||
boxShadow: glow ? `0 0 7px ${c}` : undefined,
|
||||
}}
|
||||
/>
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
{flows.length > 0 && (
|
||||
<span className="flex items-center gap-1.5 text-slate-500">
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 16,
|
||||
height: 2,
|
||||
background: "rgba(99,102,241,0.7)",
|
||||
borderTop: "1px dashed rgba(99,102,241,0.9)",
|
||||
}}
|
||||
/>
|
||||
Traffic flow
|
||||
</span>
|
||||
)}
|
||||
<span className="font-semibold font-mono" style={{ color: "#818cf8" }}>
|
||||
{pins.length} agent{pins.length !== 1 ? "s" : ""} mapped
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default AgentLocationMap;
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getFlowTooltipContent } from "../dashboard/GlobalAgentMapHelpers";
|
||||
import { EmptyMap } from "./AgentEmptyMap";
|
||||
import { MAP_CSS } from "./AgentMapCss";
|
||||
|
||||
export { EmptyMap, MAP_CSS };
|
||||
|
||||
export interface AgentLocationPin {
|
||||
agent_uuid: string;
|
||||
label: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
isOnline?: boolean;
|
||||
uptimePercent?: number;
|
||||
site_uuid?: string;
|
||||
}
|
||||
|
||||
export function buildMarkerHtml(online: boolean) {
|
||||
return `
|
||||
<div class="custom-agent-marker ${online ? 'online' : 'offline'}">
|
||||
<div class="marker-beacon-ring"></div>
|
||||
<div class="marker-glass-disc">
|
||||
<div class="marker-core-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Draw Flow lines ONLY if they exist in active flows ────────────────
|
||||
export function drawFlowLines(map: any, L: any, pins: AgentLocationPin[], flows: any[]) {
|
||||
flows.forEach((flow) => {
|
||||
const src = pins.find((p) => p.agent_uuid === flow.source);
|
||||
const dst = pins.find((p) => p.agent_uuid === flow.target);
|
||||
if (!src || !dst) return;
|
||||
|
||||
const bothOn = src.isOnline && dst.isOnline;
|
||||
const bothOff = !src.isOnline && !dst.isOnline;
|
||||
const lineColor = bothOn ? "#34d399" : bothOff ? "#f87171" : "#fbbf24";
|
||||
const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.0), 6);
|
||||
|
||||
// Background polyline for hover trigger area
|
||||
const baseLine = L.polyline(
|
||||
[[src.latitude, src.longitude], [dst.latitude, dst.longitude]],
|
||||
{ color: lineColor, weight: weight + 4, opacity: 0.15 }
|
||||
).addTo(map);
|
||||
|
||||
// Main colored line
|
||||
const activeLine = L.polyline(
|
||||
[[src.latitude, src.longitude], [dst.latitude, dst.longitude]],
|
||||
{ color: lineColor, weight: weight, opacity: 0.5 }
|
||||
).addTo(map);
|
||||
|
||||
// Moving animation line
|
||||
const animLine = L.polyline(
|
||||
[[src.latitude, src.longitude], [dst.latitude, dst.longitude]],
|
||||
{
|
||||
color: lineColor,
|
||||
weight: weight * 0.6,
|
||||
opacity: 0.85,
|
||||
dashArray: "8 12",
|
||||
className: "flow-line",
|
||||
}
|
||||
).addTo(map);
|
||||
|
||||
const tooltipHtml = getFlowTooltipContent(
|
||||
src.label,
|
||||
dst.label,
|
||||
flow.bytes,
|
||||
flow.flowsCount,
|
||||
flow.details || []
|
||||
);
|
||||
|
||||
[baseLine, activeLine, animLine].forEach((line) => {
|
||||
line.bindTooltip(tooltipHtml, { sticky: true, className: "leaflet-custom-popup shadow-2xl" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Draw Markers with Popups & Tooltips ───────────────────────────────
|
||||
export function drawMarkers(
|
||||
map: any,
|
||||
L: any,
|
||||
pins: AgentLocationPin[],
|
||||
uptimeMap: Record<string, number>,
|
||||
canEdit: boolean,
|
||||
onEditLocation?: (agentUuid: string, agentLabel: string) => void
|
||||
) {
|
||||
pins.forEach((pin) => {
|
||||
const online = pin.isOnline ?? false;
|
||||
const color = online ? "#10b981" : "#ef4444";
|
||||
const colorL = online ? "#34d399" : "#f87171";
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: buildMarkerHtml(online),
|
||||
className: "",
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
popupAnchor: [0, -18],
|
||||
tooltipAnchor: [0, -18],
|
||||
});
|
||||
|
||||
const marker = L.marker([pin.latitude, pin.longitude], { icon });
|
||||
|
||||
// ── Permanent label tooltip ──────────────────────────────────────────
|
||||
const shortLabel = pin.label.length > 22 ? pin.label.slice(0, 20) + "…" : pin.label;
|
||||
marker.bindTooltip(
|
||||
`<div style="
|
||||
padding:4px 10px 4px 8px; border-radius:8px; white-space:nowrap;
|
||||
background:rgba(10,18,36,0.93); border:1.5px solid ${colorL};
|
||||
color:${colorL}; font-size:11px; font-weight:700;
|
||||
font-family:var(--font-sans),sans-serif; letter-spacing:0.02em;
|
||||
box-shadow:0 0 10px ${color}55, 0 2px 12px rgba(0,0,0,0.7);
|
||||
display:flex; align-items:center; gap:6px;">
|
||||
<span style="width:7px;height:7px;border-radius:50%;background:${color};
|
||||
flex-shrink:0;${online ? `box-shadow:0 0 6px ${color}` : ""};"></span>
|
||||
${shortLabel}
|
||||
</div>`,
|
||||
{ permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false }
|
||||
);
|
||||
|
||||
// ── Click popup ──────────────────────────────────────────────────────
|
||||
const uptime = uptimeMap[pin.agent_uuid];
|
||||
const uptimeColor =
|
||||
(uptime ?? 100) >= 98
|
||||
? "#34d399"
|
||||
: (uptime ?? 100) >= 90
|
||||
? "#fbbf24"
|
||||
: "#f87171";
|
||||
const editBtn =
|
||||
canEdit && onEditLocation
|
||||
? `<button id="el-${pin.agent_uuid.replace(/\W/g, "")}"
|
||||
style="margin-top:10px;width:100%;padding:6px;background:rgba(99,102,241,0.15);
|
||||
border:1px solid rgba(99,102,241,0.35);border-radius:8px;color:#818cf8;
|
||||
font-size:11px;font-weight:700;cursor:pointer;font-family:var(--font-sans),sans-serif;"
|
||||
onmouseover="this.style.background='rgba(99,102,241,0.3)'"
|
||||
onmouseout="this.style.background='rgba(99,102,241,0.15)'">✎ Update Location</button>`
|
||||
: "";
|
||||
|
||||
marker.bindPopup(
|
||||
`<div style="background:#0d1526;border:1px solid ${colorL}44;border-radius:14px;padding:14px 16px;min-width:200px;font-family:var(--font-sans),sans-serif;box-shadow:0 20px 60px rgba(0,0,0,0.85);">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
|
||||
<span style="width:9px;height:9px;border-radius:50%;background:${color};${
|
||||
online ? `box-shadow:0 0 8px ${color};` : ""
|
||||
};flex-shrink:0;"></span>
|
||||
<span style="font-size:13px;font-weight:800;color:${colorL};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:170px;">${
|
||||
pin.label
|
||||
}</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:3px 12px;font-size:10.5px;font-family:monospace;">
|
||||
<span style="color:#475569;">UUID</span><span style="color:#93c5fd;">${pin.agent_uuid}</span>
|
||||
<span style="color:#475569;">Status</span><span style="color:${color};font-weight:700;">${
|
||||
online ? "● Online" : "○ Offline"
|
||||
}</span>
|
||||
<span style="color:#475569;">Lat</span><span style="color:#cbd5e1;">${pin.latitude.toFixed(6)}</span>
|
||||
<span style="color:#475569;">Lng</span><span style="color:#cbd5e1;">${pin.longitude.toFixed(6)}</span>
|
||||
${
|
||||
uptime !== undefined
|
||||
? `<span style="color:#475569;">Uptime</span><span style="color:${uptimeColor};font-weight:700;">${uptime.toFixed(
|
||||
1
|
||||
)}%</span>`
|
||||
: ""
|
||||
}
|
||||
</div>${editBtn}
|
||||
</div>`,
|
||||
{ className: "agent-popup", maxWidth: 260 }
|
||||
);
|
||||
|
||||
marker.on("popupopen", () => {
|
||||
const id = `el-${pin.agent_uuid.replace(/\W/g, "")}`;
|
||||
setTimeout(() => {
|
||||
document.getElementById(id)?.addEventListener("click", () => {
|
||||
onEditLocation?.(pin.agent_uuid, pin.label);
|
||||
map.closePopup();
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
|
||||
marker.addTo(map);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, MapPin, Loader2, CheckCircle2, AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { AgentLocationFormFields } from "./AgentLocationFormFields";
|
||||
|
||||
interface AgentLocationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
agentUuid: string;
|
||||
agentLabel: string;
|
||||
currentLocation: { latitude: number; longitude: number; label: string } | null;
|
||||
}
|
||||
|
||||
export default function AgentLocationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
agentUuid,
|
||||
agentLabel,
|
||||
currentLocation,
|
||||
}: AgentLocationModalProps) {
|
||||
const [latitude, setLatitude] = useState("");
|
||||
const [longitude, setLongitude] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
setSuccess("");
|
||||
if (currentLocation) {
|
||||
setLatitude(String(currentLocation.latitude));
|
||||
setLongitude(String(currentLocation.longitude));
|
||||
setLabel(currentLocation.label || "");
|
||||
} else {
|
||||
setLatitude("");
|
||||
setLongitude("");
|
||||
setLabel("");
|
||||
}
|
||||
}, [isOpen, currentLocation]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!latitude || !longitude) {
|
||||
setError("Latitude and Longitude are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const latVal = parseFloat(latitude);
|
||||
const lngVal = parseFloat(longitude);
|
||||
|
||||
if (isNaN(latVal) || latVal < -90 || latVal > 90) {
|
||||
setError("Latitude must be a valid number between -90 and 90.");
|
||||
return;
|
||||
}
|
||||
if (isNaN(lngVal) || lngVal < -180 || lngVal > 180) {
|
||||
setError("Longitude must be a valid number between -180 and 180.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/dashboard/agent-locations", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getViewAsHeaders()
|
||||
},
|
||||
body: JSON.stringify({
|
||||
agent_uuid: agentUuid,
|
||||
latitude: latVal,
|
||||
longitude: lngVal,
|
||||
label: label.trim(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) {
|
||||
throw new Error(data.error || "Failed to update location.");
|
||||
}
|
||||
|
||||
setSuccess("Location coordinates successfully updated!");
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "An error occurred.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async () => {
|
||||
if (!confirm("Are you sure you want to reset this agent coordinates to default (Jakarta)?")) return;
|
||||
|
||||
setIsResetting(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/agent-locations/${agentUuid}`, {
|
||||
method: "DELETE",
|
||||
headers: getViewAsHeaders(),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) {
|
||||
throw new Error(data.error || "Failed to reset location.");
|
||||
}
|
||||
|
||||
setSuccess("Location coordinates successfully reset to default.");
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "An error occurred.");
|
||||
} finally {
|
||||
setIsResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-white/10 bg-slate-900/90 p-6 shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary/25 p-2 text-primary">
|
||||
<MapPin className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">Agent Geolocation</h3>
|
||||
<p className="text-xs text-slate-400">Configure coordinates for <span className="text-primary font-medium">{agentLabel}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2.5 rounded-lg border border-red-500/10 bg-red-500/5 p-3 text-xs text-red-400">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="mb-4 flex items-start gap-2.5 rounded-lg border border-green-500/10 bg-green-500/5 p-3 text-xs text-green-400">
|
||||
<CheckCircle2 className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<AgentLocationFormFields
|
||||
latitude={latitude}
|
||||
setLatitude={setLatitude}
|
||||
longitude={longitude}
|
||||
setLongitude={setLongitude}
|
||||
label={label}
|
||||
setLabel={setLabel}
|
||||
/>
|
||||
|
||||
<div className="mt-6 flex justify-between gap-3 pt-2">
|
||||
{currentLocation ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReset}
|
||||
disabled={isResetting || isSubmitting}
|
||||
className="flex items-center gap-1.5 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-red-500/10 hover:border-red-500/20 hover:text-red-400 py-2.5 px-4 text-xs font-semibold text-slate-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isResetting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Reset Coordinates
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className="flex gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.05] py-2.5 px-4.5 text-xs font-semibold text-slate-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isResetting}
|
||||
className="flex items-center gap-1.5 rounded-xl bg-primary hover:bg-primary-hover py-2.5 px-5 text-xs font-semibold text-white shadow-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
Save Location
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export const MAP_CSS = `
|
||||
.leaflet-control-attribution { display:none !important; }
|
||||
.agent-popup .leaflet-popup-content-wrapper { background:#0a1224!important; border:1px solid rgba(99,102,241,0.25)!important; box-shadow:0 12px 36px rgba(0,0,0,0.65)!important; padding:0!important; border-radius:12px!important; }
|
||||
.agent-popup .leaflet-popup-content { margin:0!important; }
|
||||
.agent-popup .leaflet-popup-tip-container { display:none!important; }
|
||||
.agent-popup .leaflet-popup-close-button { color:#94a3b8!important; font-size:18px!important; top:8px!important; right:10px!important; }
|
||||
.agent-label-tooltip { background:transparent!important; border:none!important; box-shadow:none!important; padding:0!important; }
|
||||
.agent-label-tooltip::before { display:none!important; }
|
||||
@keyframes dash-flow { to { stroke-dashoffset:-20; } }
|
||||
.flow-line { animation: dash-flow 1.4s linear infinite; }
|
||||
|
||||
/* Tooltip and popup custom classes */
|
||||
.leaflet-custom-popup .leaflet-popup-content-wrapper {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.leaflet-custom-popup .leaflet-popup-tip {
|
||||
background: #0f172a !important;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
.leaflet-custom-popup .leaflet-popup-content {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Premium Map Marker Styles */
|
||||
.custom-agent-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.marker-beacon-ring {
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.online .marker-beacon-ring {
|
||||
border: 2px solid #10b981;
|
||||
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
.offline .marker-beacon-ring {
|
||||
border: 2px solid #ef4444;
|
||||
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
@keyframes beacon-ping {
|
||||
0% { transform: scale(0.4); opacity: 0.85; }
|
||||
50% { opacity: 0.45; }
|
||||
100% { transform: scale(1.8); opacity: 0; }
|
||||
}
|
||||
.marker-glass-disc {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: rgba(10, 18, 36, 0.85);
|
||||
border: 2px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.15);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.online .marker-glass-disc {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.offline .marker-glass-disc {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.marker-core-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.online .marker-core-dot {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px #10b981;
|
||||
}
|
||||
.offline .marker-core-dot {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 6px #ef4444;
|
||||
}
|
||||
.custom-agent-marker:hover .marker-glass-disc {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { createAgent, deleteAgent } from "@/lib/actions/agents";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
import { AgentDetailModal } from "@/components/ui/AgentDetailModal";
|
||||
import AgentAccountModal from "@/components/admin/AgentAccountModal";
|
||||
import { resolveAgentLabel, type ManagedUser } from "@/lib/admin-api";
|
||||
|
||||
interface Props {
|
||||
isCreateOpen: boolean;
|
||||
setIsCreateOpen: (open: boolean) => void;
|
||||
isDeleteOpen: boolean;
|
||||
setIsDeleteOpen: (open: boolean) => void;
|
||||
selectedAgent: Agent | null;
|
||||
onSuccess: () => void;
|
||||
// Detail modal state
|
||||
detailAgent: Agent | null;
|
||||
setDetailAgent: (agent: Agent | null) => void;
|
||||
// Account modal state
|
||||
accountModalOpen: boolean;
|
||||
setAccountModalOpen: (open: boolean) => void;
|
||||
accountModalAgent: { uuid: string; label: string } | null;
|
||||
setAccountModalAgent: (agent: { uuid: string; label: string } | null) => void;
|
||||
managedUsers: ManagedUser[];
|
||||
}
|
||||
|
||||
export function AgentModals({
|
||||
isCreateOpen,
|
||||
setIsCreateOpen,
|
||||
isDeleteOpen,
|
||||
setIsDeleteOpen,
|
||||
selectedAgent,
|
||||
onSuccess,
|
||||
detailAgent,
|
||||
setDetailAgent,
|
||||
accountModalOpen,
|
||||
setAccountModalOpen,
|
||||
accountModalAgent,
|
||||
setAccountModalAgent,
|
||||
managedUsers
|
||||
}: Props) {
|
||||
const [formData, setFormData] = useState({ agentId: "", label: "" });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
const res = await createAgent(formData.agentId, formData.label);
|
||||
setIsSubmitting(false);
|
||||
if (res.success) {
|
||||
setIsCreateOpen(false);
|
||||
setFormData({ agentId: "", label: "" });
|
||||
onSuccess();
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selectedAgent) return;
|
||||
setIsSubmitting(true);
|
||||
const res = await deleteAgent(selectedAgent.uuid || selectedAgent.serial);
|
||||
setIsSubmitting(false);
|
||||
if (res.success) {
|
||||
setIsDeleteOpen(false);
|
||||
onSuccess();
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} title="Provision New Agent">
|
||||
<form onSubmit={handleCreate} className="space-y-4 text-xs">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Agent UUID / Serial *</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
placeholder="e.g. EX-AM-PL-ES"
|
||||
value={formData.agentId}
|
||||
onChange={e => setFormData({...formData, agentId: e.target.value})}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Agent Label</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Branch Office Gateway"
|
||||
value={formData.label}
|
||||
onChange={e => setFormData({...formData, label: e.target.value})}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setIsCreateOpen(false)} className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors">Cancel</button>
|
||||
<button type="submit" disabled={isSubmitting} 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 disabled:opacity-50">
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Create Agent
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Modal */}
|
||||
<Modal isOpen={isDeleteOpen} onClose={() => setIsDeleteOpen(false)} title="Confirm Deletion">
|
||||
<div className="space-y-4 text-xs">
|
||||
<p className="text-sm text-slate-300">
|
||||
Are you sure you want to delete the agent <span className="font-bold text-white">{selectedAgent?.label || selectedAgent?.uuid}</span>? This action cannot be undone.
|
||||
</p>
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setIsDeleteOpen(false)} className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors">Cancel</button>
|
||||
<button onClick={handleDelete} disabled={isSubmitting} className="flex items-center gap-2 bg-destructive hover:bg-destructive/90 text-destructive-foreground px-4 py-2 rounded-xl text-sm font-semibold transition-all disabled:opacity-50">
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Delete Agent
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Agent Detail Modal */}
|
||||
{detailAgent && (
|
||||
<AgentDetailModal
|
||||
agentUuid={detailAgent.uuid || detailAgent.serial}
|
||||
agentLabel={resolveAgentLabel(detailAgent.uuid || detailAgent.serial, managedUsers) !== (detailAgent.uuid || detailAgent.serial)
|
||||
? resolveAgentLabel(detailAgent.uuid || detailAgent.serial, managedUsers)
|
||||
: (detailAgent.label || detailAgent.uuid || detailAgent.serial)}
|
||||
onClose={() => setDetailAgent(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Agent Account Modal */}
|
||||
{accountModalOpen && accountModalAgent && (
|
||||
<AgentAccountModal
|
||||
isOpen={accountModalOpen}
|
||||
onClose={() => { setAccountModalOpen(false); setAccountModalAgent(null); }}
|
||||
onSuccess={onSuccess}
|
||||
agentUuid={accountModalAgent.uuid}
|
||||
agentLabel={accountModalAgent.label}
|
||||
existingUser={managedUsers.find(u => u.agent_uuid === accountModalAgent.uuid && u.role === 'AGENT_VIEWER') || null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "@/components/ui/sharedPdfStyles";
|
||||
import { DeviceLabelItem } from "@/app/(dashboard)/device-labeling/columns";
|
||||
|
||||
interface DeviceLabelingPdfDocumentProps {
|
||||
devices: DeviceLabelItem[];
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
export default function DeviceLabelingPdfDocument({
|
||||
devices,
|
||||
reportDateStr,
|
||||
}: DeviceLabelingPdfDocumentProps) {
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>Device Asset Directory & Owner Management Report</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Device Asset Directory</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Section Title */}
|
||||
<Text style={sharedStyles.sectionTitle}>
|
||||
Registered MAC Address Directory ({devices.length} Assets)
|
||||
</Text>
|
||||
|
||||
{/* Table */}
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
<View style={{ width: "5%" }}><Text style={sharedStyles.tableHeaderCell}>#</Text></View>
|
||||
<View style={{ width: "22%" }}><Text style={sharedStyles.tableHeaderCell}>MAC Address</Text></View>
|
||||
<View style={{ width: "22%" }}><Text style={sharedStyles.tableHeaderCell}>Owner / Custom Label</Text></View>
|
||||
<View style={{ width: "21%" }}><Text style={sharedStyles.tableHeaderCell}>System Label</Text></View>
|
||||
<View style={{ width: "16%" }}><Text style={sharedStyles.tableHeaderCell}>Manufacturer</Text></View>
|
||||
<View style={{ width: "14%" }}><Text style={sharedStyles.tableHeaderCell}>Agent</Text></View>
|
||||
</View>
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No device assets recorded.</Text>
|
||||
</View>
|
||||
) : (
|
||||
devices.map((device, idx) => (
|
||||
<View key={device.mac_address || idx} style={sharedStyles.tableRow} wrap={false}>
|
||||
<View style={{ width: "5%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{idx + 1}</Text>
|
||||
</View>
|
||||
<View style={{ width: "22%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, fontFamily: "Helvetica-Bold" }}>
|
||||
{device.mac_address}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: "22%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, color: device.custom_label ? "#0284c7" : "#94a3b8" }}>
|
||||
{device.custom_label || "— Unassigned —"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: "21%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{device.default_label || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ width: "16%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{device.manufacturer || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ width: "14%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, color: "#7e22ce" }}>{device.agent_name || "—"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { Loader2, Monitor, Shield, Tag, Cpu, Clock, Network, ArrowDownToLine } from "lucide-react";
|
||||
|
||||
interface IpHistoryItem {
|
||||
ip_address: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flows: number;
|
||||
}
|
||||
|
||||
interface DeviceMacDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
macAddress: string;
|
||||
customLabel: string | null;
|
||||
defaultLabel: string;
|
||||
agentName: string;
|
||||
manufacturer: string;
|
||||
deviceType: string;
|
||||
}
|
||||
|
||||
export default function DeviceMacDetailsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
macAddress,
|
||||
customLabel,
|
||||
defaultLabel,
|
||||
agentName,
|
||||
manufacturer,
|
||||
deviceType,
|
||||
}: DeviceMacDetailsModalProps) {
|
||||
const [ips, setIps] = useState<IpHistoryItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const exportToPdf = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { pdf } = await import("@react-pdf/renderer");
|
||||
const { default: DeviceMacPdfDocument } = await import("./DeviceMacPdfDocument");
|
||||
const reportDateStr = new Date().toLocaleString("id-ID", { timeZone: "Asia/Jakarta", day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" }) + " WIB";
|
||||
const doc = <DeviceMacPdfDocument macAddress={macAddress} customLabel={customLabel} defaultLabel={defaultLabel} agentName={agentName} manufacturer={manufacturer} deviceType={deviceType} ips={ips} reportDateStr={reportDateStr} />;
|
||||
const blob = await pdf(doc).toBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `Device_MAC_Details_${macAddress.replace(/[:]/g, "")}.pdf`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Failed to export PDF:", err);
|
||||
alert("Failed to generate PDF report. Please try again.");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !macAddress) return;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
fetch(`/api/dashboard/devices/mac-details?mac=${encodeURIComponent(macAddress)}`, { headers: getViewAsHeaders() })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.ok) setIps(data.ips || []);
|
||||
else setError(data.error || "Failed to load device details.");
|
||||
})
|
||||
.catch((err) => setError(err.message || "An error occurred while loading details."))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [isOpen, macAddress]);
|
||||
|
||||
const DateCell = ({ dateStr }: { dateStr?: string }) => {
|
||||
if (!dateStr) return <span>-</span>;
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
const datePart = d.toLocaleDateString("id-ID", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
const timePart = d.toLocaleTimeString("id-ID", { hour: "2-digit", minute: "2-digit" }) + " WIB";
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center leading-tight py-0.5" title={`${datePart} ${timePart}`}>
|
||||
<span className="font-medium text-slate-300 text-[11px] whitespace-nowrap">{datePart}</span>
|
||||
<span className="text-[10px] text-slate-500 whitespace-nowrap">{timePart}</span>
|
||||
</div>
|
||||
);
|
||||
} catch { return <span>{dateStr}</span>; }
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="🔍 Device Network Details"
|
||||
>
|
||||
<div className="space-y-6 max-h-[75vh] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{/* Profile Card */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Tag className="w-3 h-3" /> MAC Address
|
||||
</span>
|
||||
<code className="text-xs text-white font-mono block truncate" title={macAddress}>{macAddress}</code>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Cpu className="w-3 h-3" /> Manufacturer
|
||||
</span>
|
||||
<span className="text-xs text-slate-200 block truncate" title={manufacturer}>
|
||||
{manufacturer || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1 col-span-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Monitor className="w-3 h-3" /> Device Labels
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-slate-500 font-medium">Custom:</span>
|
||||
{customLabel ? (
|
||||
<span className="text-xs font-semibold text-cyan-400 bg-cyan-400/10 border border-cyan-400/20 px-2 py-0.5 rounded-full">
|
||||
{customLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 italic">No label configured</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-slate-500 font-medium">System:</span>
|
||||
<span className="text-xs text-slate-400 truncate">{defaultLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" /> Device Type
|
||||
</span>
|
||||
<span className="text-xs text-slate-200 block capitalize truncate" title={deviceType}>{deviceType || "Unknown"}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Network className="w-3 h-3" /> Network Agent
|
||||
</span>
|
||||
<span className="text-xs text-purple-400 font-semibold block truncate" title={agentName}>
|
||||
{agentName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IPs Section */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center gap-2">
|
||||
🔗 Associated IP Addresses
|
||||
</h4>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="py-8 flex justify-center items-center gap-2 text-slate-400 text-xs">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
Resolving active IP address histories...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-xs">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
) : ips.length === 0 ? (
|
||||
<div className="py-8 text-center text-slate-500 italic text-xs border border-white/5 rounded-xl bg-white/[0.01]">
|
||||
No IP addresses recorded for this MAC address.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-white/10 rounded-xl overflow-x-auto overflow-y-auto max-h-60 custom-scrollbar bg-[#090D16]">
|
||||
<table className="w-full text-xs text-slate-300 border-collapse table-fixed min-w-[520px]">
|
||||
<thead className="bg-[#0F172A] text-slate-300 uppercase text-[9px] tracking-wider border-b border-white/10 sticky top-0 z-10">
|
||||
<tr className="bg-[#0F172A]">
|
||||
<th className="px-2.5 py-2.5 text-center w-[25%]">IP Address</th>
|
||||
<th className="px-2 py-2.5 text-center w-[20%]">First Seen</th>
|
||||
<th className="px-2 py-2.5 text-center w-[20%]">Last Seen</th>
|
||||
<th className="px-2 py-2.5 text-center w-[23%]">Traffic (Down / Up)</th>
|
||||
<th className="px-2 py-2.5 text-center w-[12%]">Flows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{ips.map((ip) => (
|
||||
<tr key={ip.ip_address} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-2.5 py-2.5 font-mono text-center text-slate-200">
|
||||
<div className="truncate w-full text-center font-semibold text-cyan-300 text-xs" title={ip.ip_address}>
|
||||
{ip.ip_address}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-center text-slate-400">
|
||||
<DateCell dateStr={ip.first_seen} />
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-center text-slate-400">
|
||||
<DateCell dateStr={ip.last_seen} />
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-center font-mono text-[11px] whitespace-nowrap">
|
||||
<span className="text-cyan-400 font-semibold">{fmtBytes(ip.download)}</span>
|
||||
<span className="text-slate-600 mx-1">/</span>
|
||||
<span className="text-emerald-400 font-semibold">{fmtBytes(ip.upload)}</span>
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-center font-mono text-cyan-400 font-semibold text-xs">
|
||||
{ip.flows?.toLocaleString() ?? 0}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isExporting || isLoading}
|
||||
onClick={exportToPdf}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-xs font-semibold transition-all disabled:opacity-50"
|
||||
>
|
||||
{isExporting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <ArrowDownToLine className="w-3.5 h-3.5" />}
|
||||
<span>{isExporting ? "Generating..." : "Export PDF"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white rounded-lg text-xs font-semibold transition-all"
|
||||
>
|
||||
Close Details
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "@/components/ui/sharedPdfStyles";
|
||||
|
||||
interface IpHistoryItem {
|
||||
ip_address: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flows: number;
|
||||
}
|
||||
|
||||
interface DeviceMacPdfDocumentProps {
|
||||
macAddress: string;
|
||||
customLabel: string | null;
|
||||
defaultLabel: string;
|
||||
agentName: string;
|
||||
manufacturer: string;
|
||||
deviceType: string;
|
||||
ips: IpHistoryItem[];
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1000;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr?: string): string {
|
||||
if (!dateStr) return "—";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
export default function DeviceMacPdfDocument({
|
||||
macAddress,
|
||||
customLabel,
|
||||
defaultLabel,
|
||||
agentName,
|
||||
manufacturer,
|
||||
deviceType,
|
||||
ips,
|
||||
reportDateStr,
|
||||
}: DeviceMacPdfDocumentProps) {
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>MAC Address Network Intelligence Report</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Device Network Details Report</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Profile Card */}
|
||||
<Text style={sharedStyles.sectionTitle}>Device Identification Profile</Text>
|
||||
<View style={sharedStyles.grid}>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "33%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>MAC Address</Text>
|
||||
<Text style={sharedStyles.gridValue}>{macAddress || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "33%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Owner / Custom Label</Text>
|
||||
<Text style={{ ...sharedStyles.gridValue, color: customLabel ? "#0284c7" : "#64748b" }}>
|
||||
{customLabel || "— Unassigned —"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "34%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>System Label</Text>
|
||||
<Text style={sharedStyles.gridValue}>{defaultLabel || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "33%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Manufacturer</Text>
|
||||
<Text style={sharedStyles.gridValue}>{manufacturer || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "33%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Device Type</Text>
|
||||
<Text style={sharedStyles.gridValue}>{deviceType || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "34%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Network Agent</Text>
|
||||
<Text style={{ ...sharedStyles.gridValue, color: "#7e22ce" }}>{agentName || "N/A"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Associated IPs Section */}
|
||||
<Text style={sharedStyles.sectionTitle}>
|
||||
Associated IP Address History ({ips.length} Records)
|
||||
</Text>
|
||||
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
<View style={{ width: "25%" }}><Text style={sharedStyles.tableHeaderCell}>IP Address</Text></View>
|
||||
<View style={{ width: "22%" }}><Text style={sharedStyles.tableHeaderCell}>First Seen</Text></View>
|
||||
<View style={{ width: "22%" }}><Text style={sharedStyles.tableHeaderCell}>Last Seen</Text></View>
|
||||
<View style={{ width: "18%", textAlign: "right" }}><Text style={{ ...sharedStyles.tableHeaderCell, textAlign: "right" }}>Traffic (Down/Up)</Text></View>
|
||||
<View style={{ width: "13%", textAlign: "right" }}><Text style={{ ...sharedStyles.tableHeaderCell, textAlign: "right" }}>Flows</Text></View>
|
||||
</View>
|
||||
|
||||
{ips.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No IP history recorded for this MAC address.</Text>
|
||||
</View>
|
||||
) : (
|
||||
ips.map((ip, idx) => (
|
||||
<View key={ip.ip_address || idx} style={sharedStyles.tableRow} wrap={false}>
|
||||
<View style={{ width: "25%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, fontFamily: "Helvetica-Bold", color: "#0f172a" }}>
|
||||
{ip.ip_address}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: "22%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{formatIndonesiaTime(ip.first_seen)}</Text>
|
||||
</View>
|
||||
<View style={{ width: "22%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{formatIndonesiaTime(ip.last_seen)}</Text>
|
||||
</View>
|
||||
<View style={{ width: "18%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: "right" }}>
|
||||
{formatBytes(ip.download)} / {formatBytes(ip.upload)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: "13%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: "right", color: "#0284c7" }}>
|
||||
{ip.flows?.toLocaleString() ?? 0}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { User, Key, Tag, Shield } from "lucide-react";
|
||||
import RoleSelect from "./RoleSelect";
|
||||
import AgentChecklist from "./AgentChecklist";
|
||||
import ProfilePictureInput from "./ProfilePictureInput";
|
||||
|
||||
interface ExternalAccountFormFieldsProps {
|
||||
isEdit: boolean;
|
||||
adminRole: string;
|
||||
modalContext: 'agents' | 'user-accounts';
|
||||
previewPic: string | null;
|
||||
handlePicChange: (file: File) => void;
|
||||
accountName: string;
|
||||
setAccountName: (val: string) => void;
|
||||
role: string;
|
||||
setRole: (val: string) => void;
|
||||
companyName: string;
|
||||
setCompanyName: (val: string) => void;
|
||||
availableAgents: string[];
|
||||
selectedAgents: string[];
|
||||
handleAgentCheck: (agentUuid: string, checked: boolean) => void;
|
||||
agentNamesMap: Record<string, string>;
|
||||
username: string;
|
||||
setUsername: (val: string) => void;
|
||||
password: string;
|
||||
setPassword: (val: string) => void;
|
||||
}
|
||||
|
||||
export function ExternalAccountFormFields({
|
||||
isEdit,
|
||||
adminRole,
|
||||
modalContext,
|
||||
previewPic,
|
||||
handlePicChange,
|
||||
accountName,
|
||||
setAccountName,
|
||||
role,
|
||||
setRole,
|
||||
companyName,
|
||||
setCompanyName,
|
||||
availableAgents,
|
||||
selectedAgents,
|
||||
handleAgentCheck,
|
||||
agentNamesMap,
|
||||
username,
|
||||
setUsername,
|
||||
password,
|
||||
setPassword,
|
||||
}: ExternalAccountFormFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<ProfilePictureInput previewPic={previewPic} onChange={handlePicChange} />
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={(e) => setAccountName(e.target.value)}
|
||||
placeholder="Full Name"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Shield className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Role
|
||||
</label>
|
||||
<RoleSelect adminRole={adminRole} value={role} onChange={setRole} modalContext={modalContext} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalContext === 'user-accounts' && adminRole === "SUPER_ADMIN" && (role === "COMPANY_ADMIN" || isEdit) && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
🏢 Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Nama Perusahaan (e.g. IFG)"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalContext === 'user-accounts' && (
|
||||
role === "COMPANY_ADMIN" ||
|
||||
role === "COMPANY_OPERATOR" ||
|
||||
role === "COMPANY_VIEWER"
|
||||
) && (
|
||||
<AgentChecklist
|
||||
availableAgents={availableAgents}
|
||||
selectedAgents={selectedAgents}
|
||||
onAgentCheck={handleAgentCheck}
|
||||
agentNamesMap={agentNamesMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Login Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username for login"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
{isEdit ? "Reset Password" : "Password"}{" "}
|
||||
{isEdit && (
|
||||
<span className="text-slate-500">(leave blank to keep unchanged)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "Type new password to override" : "Create password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Loader2, AlertCircle, CheckCircle2, Trash2 } from "lucide-react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { useExternalAccountForm } from "./useExternalAccountForm";
|
||||
import { ExternalAccountFormFields } from "./ExternalAccountFormFields";
|
||||
|
||||
interface ExternalAccountModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
user?: ManagedUser | null;
|
||||
targetSiteUuid?: string | null;
|
||||
targetCreatedBy?: string | null;
|
||||
targetCompany?: string | null;
|
||||
modalContext?: 'agents' | 'user-accounts';
|
||||
}
|
||||
|
||||
export default function ExternalAccountModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
user,
|
||||
targetCompany,
|
||||
modalContext = 'user-accounts',
|
||||
}: ExternalAccountModalProps) {
|
||||
const {
|
||||
adminRole,
|
||||
username,
|
||||
setUsername,
|
||||
password,
|
||||
setPassword,
|
||||
accountName,
|
||||
setAccountName,
|
||||
role,
|
||||
setRole,
|
||||
companyName,
|
||||
setCompanyName,
|
||||
selectedAgents,
|
||||
previewPic,
|
||||
isSubmitting,
|
||||
isDeleting,
|
||||
success,
|
||||
error,
|
||||
availableAgents,
|
||||
agentNamesMap,
|
||||
handlePicChange,
|
||||
handleAgentCheck,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
} = useExternalAccountForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
user,
|
||||
targetCompany,
|
||||
modalContext,
|
||||
});
|
||||
|
||||
const isEdit = !!user;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-amber-900/40 to-slate-900 shrink-0">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{isEdit
|
||||
? "Manage Account"
|
||||
: modalContext === 'agents'
|
||||
? "Create Technical Account"
|
||||
: "Create Company Account"}
|
||||
</h3>
|
||||
{isEdit && (
|
||||
<p className="text-xs text-slate-400 mt-0.5 font-mono">
|
||||
Role:{" "}
|
||||
<span className="font-semibold text-amber-400">
|
||||
{user.role.replace("_", " ")}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="p-5 space-y-4 overflow-y-auto flex-1 custom-scrollbar"
|
||||
>
|
||||
{isEdit && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 text-xs text-amber-200/80 mb-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<p>
|
||||
<strong>Password Recovery:</strong> For security reasons, original passwords cannot
|
||||
be viewed. To recover an account, enter a new password below to reset it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ExternalAccountFormFields
|
||||
isEdit={isEdit}
|
||||
adminRole={adminRole}
|
||||
modalContext={modalContext}
|
||||
previewPic={previewPic}
|
||||
handlePicChange={handlePicChange}
|
||||
accountName={accountName}
|
||||
setAccountName={setAccountName}
|
||||
role={role}
|
||||
setRole={setRole}
|
||||
companyName={companyName}
|
||||
setCompanyName={setCompanyName}
|
||||
availableAgents={availableAgents}
|
||||
selectedAgents={selectedAgents}
|
||||
handleAgentCheck={handleAgentCheck}
|
||||
agentNamesMap={agentNamesMap}
|
||||
username={username}
|
||||
setUsername={setUsername}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-900/30 border border-green-800 text-green-300 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-2 shrink-0">
|
||||
{isEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting || isSubmitting}
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm hover:bg-red-900/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isDeleting || isSubmitting}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border border-slate-600 text-slate-300 text-sm hover:bg-slate-800 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || isDeleting}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
{isEdit ? "Update & Reset" : "Create Account"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
interface ProfilePictureInputProps {
|
||||
previewPic: string | null;
|
||||
onChange: (file: File) => void;
|
||||
}
|
||||
|
||||
export default function ProfilePictureInput({
|
||||
previewPic,
|
||||
onChange,
|
||||
}: ProfilePictureInputProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onChange(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full border-2 border-dashed border-slate-600 flex items-center justify-center cursor-pointer hover:border-amber-500 transition-colors overflow-hidden bg-slate-800"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{previewPic ? (
|
||||
<img src={previewPic} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">Profile Picture</p>
|
||||
<p className="text-xs text-slate-500">Click to upload image</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ChevronDown, Shield } from "lucide-react";
|
||||
|
||||
interface RoleOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface RoleSelectProps {
|
||||
adminRole: string;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
modalContext?: 'agents' | 'user-accounts';
|
||||
}
|
||||
|
||||
// Role untuk konteks Agents (teknikal/operasional)
|
||||
const AGENTS_ROLES: RoleOption[] = [
|
||||
{ value: "EXECUTIVE", label: "Executive" },
|
||||
{ value: "TENANT_ADMIN", label: "Tenant Admin" },
|
||||
{ value: "SOC_ANALYST", label: "SOC Analyst" },
|
||||
{ value: "ENGINEER", label: "Engineer" },
|
||||
];
|
||||
|
||||
// Role untuk konteks User Accounts (customer/client)
|
||||
const USER_ACCOUNTS_ROLES_SUPER: RoleOption[] = [
|
||||
{ value: "COMPANY_ADMIN", label: "Company Admin" },
|
||||
{ value: "COMPANY_OPERATOR", label: "Company Operator" },
|
||||
{ value: "COMPANY_VIEWER", label: "Company Viewer" },
|
||||
];
|
||||
|
||||
const USER_ACCOUNTS_ROLES_COMPANY: RoleOption[] = [
|
||||
{ value: "COMPANY_OPERATOR", label: "Company Operator" },
|
||||
{ value: "COMPANY_VIEWER", label: "Company Viewer" },
|
||||
];
|
||||
|
||||
export default function RoleSelect({ adminRole, value, onChange, modalContext = 'user-accounts' }: RoleSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Pilih role options berdasarkan konteks modal
|
||||
const options: RoleOption[] = (() => {
|
||||
if (modalContext === 'agents') {
|
||||
// Agents page: selalu tampilkan role teknikal saja
|
||||
return AGENTS_ROLES;
|
||||
}
|
||||
// User Accounts page: tampilkan COMPANY_* sesuai admin role
|
||||
if (adminRole === 'SUPER_ADMIN' || adminRole === 'EXECUTIVE') return USER_ACCOUNTS_ROLES_SUPER;
|
||||
return USER_ACCOUNTS_ROLES_COMPANY;
|
||||
})();
|
||||
|
||||
const currentOption = options.find((opt) => opt.value === value) || options[0];
|
||||
|
||||
// Set default role if current value is not in the options
|
||||
useEffect(() => {
|
||||
if (options.length > 0 && !options.some((opt) => opt.value === value)) {
|
||||
onChange(options[0].value);
|
||||
}
|
||||
}, [adminRole, value, onChange, options]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
return () => document.removeEventListener("mousedown", handleOutsideClick);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:border-amber-500 hover:border-slate-500 transition-colors text-left"
|
||||
>
|
||||
<span className="truncate">{currentOption?.label || value}</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-slate-400 transition-transform duration-300 ${
|
||||
isOpen ? "rotate-180 text-amber-500" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 max-h-60 overflow-y-auto bg-slate-900 border border-slate-700 rounded-lg shadow-xl z-50 py-1 custom-scrollbar">
|
||||
{options.map((opt) => {
|
||||
const isSelected = opt.value === value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-sm text-left transition-colors truncate flex items-center justify-between ${
|
||||
isSelected
|
||||
? "bg-amber-600/20 text-amber-400 font-medium"
|
||||
: "text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{isSelected && <Shield className="w-3.5 h-3.5 text-amber-400" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Plus, Trash2, Network, Save, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface SubnetConfigModalProps {
|
||||
agentUuid: string;
|
||||
agentLabel: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SubnetConfigModal({ agentUuid, agentLabel, open, onClose }: SubnetConfigModalProps) {
|
||||
const [subnets, setSubnets] = useState<string[]>([]);
|
||||
const [newSubnet, setNewSubnet] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !agentUuid) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
fetch(`/api/dashboard/agents/${agentUuid}/subnets`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.ok) setSubnets(data.data?.allowed_subnets || []);
|
||||
else setError(data.error || "Gagal memuat data");
|
||||
})
|
||||
.catch(() => setError("Koneksi gagal"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, agentUuid]);
|
||||
|
||||
const addSubnet = () => {
|
||||
let val = newSubnet.trim();
|
||||
if (!val) return;
|
||||
if (val.endsWith('.')) val = val.slice(0, -1);
|
||||
|
||||
if (val.includes('/')) {
|
||||
const [ip, bits] = val.split('/');
|
||||
const ipParts = ip.split('.');
|
||||
if (ipParts.length !== 4 || ipParts.some(p => p === "" || isNaN(Number(p)) || Number(p) > 255) || isNaN(Number(bits)) || Number(bits) < 0 || Number(bits) > 32) {
|
||||
setError("Format CIDR tidak valid. Contoh: 192.168.15.0/24");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const parts = val.split(".");
|
||||
if (parts.some(p => p !== "" && (isNaN(Number(p)) || Number(p) > 255))) {
|
||||
setError("Format subnet tidak valid. Contoh: 192.168 atau 10.21.5");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (subnets.includes(val)) {
|
||||
setError("Subnet sudah ada");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setSubnets(prev => [...prev, val]);
|
||||
setNewSubnet("");
|
||||
};
|
||||
|
||||
const removeSubnet = (idx: number) => {
|
||||
setSubnets(prev => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard/agents/${agentUuid}/subnets`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ allowed_subnets: subnets }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
} else {
|
||||
setError(data.error || "Gagal menyimpan");
|
||||
}
|
||||
} catch {
|
||||
setError("Koneksi gagal");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="relative z-10 w-full max-w-lg mx-4 bg-slate-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-violet-500/10 border border-violet-500/20 rounded-lg">
|
||||
<Network className="w-4 h-4 text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">Konfigurasi Subnet IP</h2>
|
||||
<p className="text-xs text-slate-500 font-mono">{agentLabel || agentUuid}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-slate-500 hover:text-white hover:bg-white/10 transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Info banner */}
|
||||
<div className="flex gap-3 p-3 rounded-xl bg-amber-500/5 border border-amber-500/15">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-amber-300/80 leading-relaxed">
|
||||
Daftarkan prefix subnet IP yang <strong className="text-amber-300">hanya milik agen ini</strong>.
|
||||
Saat sinkronisasi data, IP yang tidak cocok dengan subnet di bawah ini akan otomatis dibuang
|
||||
agar tidak bercampur dengan agen lain.
|
||||
<br /><br />
|
||||
<span className="text-slate-400">Contoh format valid:<br/>
|
||||
• <code className="bg-slate-800 px-1 rounded text-amber-300">192.168.10.0/24</code> atau <code className="bg-slate-800 px-1 rounded text-amber-300">10.8.60.0/20</code> (Format CIDR)<br/>
|
||||
• <code className="bg-slate-800 px-1 rounded text-amber-300">192.168.10</code> (Semua IP berawalan 192.168.10)<br/>
|
||||
• <code className="bg-slate-800 px-1 rounded text-amber-300">10.21.7.72</code> (Hanya IP Spesifik)</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Subnet list */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Subnet yang Diizinkan</label>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-violet-500/30 border-t-violet-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
) : subnets.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-white/10 py-6 text-center">
|
||||
<Network className="w-6 h-6 text-slate-600 mx-auto mb-2" />
|
||||
<p className="text-xs text-slate-500">Belum ada subnet dikonfigurasi.</p>
|
||||
<p className="text-xs text-slate-600">Semua IP akan diterima (tidak difilter).</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto custom-scrollbar pr-1">
|
||||
{subnets.map((s, i) => (
|
||||
<div key={i} className="flex items-center justify-between gap-2 px-3 py-2 bg-white/[0.03] border border-white/[0.06] rounded-lg group">
|
||||
<span className="font-mono text-sm text-violet-300">
|
||||
{s.includes('/') || s.split('.').length === 4 ? s : `${s}.x`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeSubnet(i)}
|
||||
className="p-1 text-slate-600 hover:text-red-400 hover:bg-red-950/30 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Hapus subnet ini"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add new subnet */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Tambah Subnet Baru</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubnet}
|
||||
onChange={e => { setNewSubnet(e.target.value); setError(""); }}
|
||||
onKeyDown={e => e.key === "Enter" && addSubnet()}
|
||||
placeholder="Contoh: 192.168.10.0/24 atau 10.21"
|
||||
className="flex-1 bg-white/[0.03] border border-white/10 rounded-xl px-3 py-2 text-sm text-white font-mono placeholder:text-slate-600 focus:outline-none focus:border-violet-500/50 focus:bg-white/[0.05] transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={addSubnet}
|
||||
className="px-3 py-2 bg-violet-600/20 border border-violet-500/30 hover:bg-violet-600/30 text-violet-400 rounded-xl transition-colors flex items-center gap-1.5 text-xs font-medium"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Tambah
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error / Success */}
|
||||
{error && <p className="text-xs text-red-400 bg-red-500/10 border border-red-500/20 rounded-lg px-3 py-2">{error}</p>}
|
||||
{success && <p className="text-xs text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-3 py-2">✓ Konfigurasi subnet berhasil disimpan!</p>}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t border-white/10 bg-white/[0.02]">
|
||||
<button onClick={onClose} className="px-4 py-2 text-xs text-slate-400 hover:text-white hover:bg-white/10 rounded-xl transition-colors">
|
||||
Tutup
|
||||
</button>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-violet-600 hover:bg-violet-500 disabled:opacity-50 text-white text-xs font-medium rounded-xl transition-colors"
|
||||
>
|
||||
{saving ? <div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{saving ? "Menyimpan..." : "Simpan"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Eye, ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { getViewAsStatusSync, stopViewAs, type ViewAsStatus } from "@/lib/admin-api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function ViewAsAgentBanner() {
|
||||
const router = useRouter();
|
||||
const [status, setStatus] = useState<ViewAsStatus>({ active: false });
|
||||
const [isLeaving, setIsLeaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Baca dari localStorage (sync) karena token disimpan di sana
|
||||
const current = getViewAsStatusSync();
|
||||
setStatus(current);
|
||||
}, []);
|
||||
|
||||
if (!status.active) return null;
|
||||
|
||||
const handleLeave = async () => {
|
||||
setIsLeaving(true);
|
||||
try {
|
||||
await stopViewAs(); // Hapus dari localStorage
|
||||
setStatus({ active: false });
|
||||
// Force reload agar semua data di-refresh tanpa view-as filter
|
||||
window.location.reload();
|
||||
} catch {
|
||||
setIsLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full bg-gradient-to-r from-amber-900/40 via-amber-800/30 to-amber-900/40 backdrop-blur-md border-b border-amber-500/20 px-4 py-2 flex items-center justify-between z-40">
|
||||
<div className="flex items-center gap-2 text-amber-200">
|
||||
<Eye className="w-4 h-4 text-amber-400 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
Melihat sebagai:{" "}
|
||||
<span className="font-semibold text-amber-100">{status.agent_label}</span>
|
||||
<span className="text-amber-400 font-mono text-xs ml-1">({status.agent_uuid})</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLeave}
|
||||
disabled={isLeaving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-700/50 hover:bg-amber-700 border border-amber-600 text-amber-100 text-xs font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLeaving ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
)}
|
||||
Return to Admin
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { deleteAdminAgentUser, getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { submitExternalAccountForm } from "./useExternalAccountSubmit";
|
||||
|
||||
interface UseExternalAccountFormProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
user?: ManagedUser | null;
|
||||
targetCompany?: string | null;
|
||||
modalContext?: 'agents' | 'user-accounts';
|
||||
}
|
||||
|
||||
export function useExternalAccountForm({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
user,
|
||||
targetCompany,
|
||||
modalContext = 'user-accounts',
|
||||
}: UseExternalAccountFormProps) {
|
||||
const [adminRole, setAdminRole] = useState<string>("");
|
||||
const [adminCompany, setAdminCompany] = useState<string>("");
|
||||
const [adminAgentUuids, setAdminAgentUuids] = useState<string[]>([]);
|
||||
const [globalAgents, setGlobalAgents] = useState<string[]>([]);
|
||||
const [agentNamesMap, setAgentNamesMap] = useState<Record<string, string>>({});
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [accountName, setAccountName] = useState("");
|
||||
const [role, setRole] = useState("COMPANY_ADMIN");
|
||||
const [companyName, setCompanyName] = useState("");
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
const [previewPic, setPreviewPic] = useState<string | null>(null);
|
||||
const [picFile, setPicFile] = useState<File | null>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
fetch("/api/auth/me", { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
setAdminRole(data.user.role || "");
|
||||
setAdminCompany(data.user.company_name || "");
|
||||
setAdminAgentUuids(data.user.agent_uuids || []);
|
||||
|
||||
if (modalContext === 'agents') {
|
||||
setRole("TENANT_ADMIN");
|
||||
} else if (data.user.role === "COMPANY_ADMIN") {
|
||||
setRole("COMPANY_OPERATOR");
|
||||
} else {
|
||||
setRole("COMPANY_ADMIN");
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetch("/api/dashboard/agents", { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
if (json.ok && json.agents) {
|
||||
setGlobalAgents(json.agents);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetch("/api/auth/admin/users", { credentials: "include", headers: getViewAsHeaders() })
|
||||
.then(res => {
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.ok && Array.isArray(data.data)) {
|
||||
const map: Record<string, string> = {};
|
||||
data.data.forEach((u: any) => {
|
||||
if (u.role === "AGENT_VIEWER" && u.agent_uuid) {
|
||||
map[u.agent_uuid] = u.account_name || u.agent_uuid;
|
||||
}
|
||||
});
|
||||
setAgentNamesMap(map);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [isOpen, modalContext]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setPicFile(null);
|
||||
if (user) {
|
||||
setUsername(user.username);
|
||||
setAccountName(user.account_name || "");
|
||||
setRole(user.role);
|
||||
setCompanyName(user.company_name || "");
|
||||
setSelectedAgents(user.agent_uuids || []);
|
||||
setPassword("");
|
||||
setPreviewPic(
|
||||
user.profile_picture
|
||||
? `/api/auth/uploads/${user.profile_picture}`
|
||||
: null
|
||||
);
|
||||
} else {
|
||||
setUsername("");
|
||||
setAccountName("");
|
||||
setCompanyName(targetCompany || "");
|
||||
setSelectedAgents([]);
|
||||
setPassword("");
|
||||
setPreviewPic(null);
|
||||
}
|
||||
}, [isOpen, user, targetCompany]);
|
||||
|
||||
const handlePicChange = (file: File) => {
|
||||
setPicFile(file);
|
||||
setPreviewPic(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const handleAgentCheck = (agentUuid: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedAgents(prev => [...prev, agentUuid]);
|
||||
} else {
|
||||
setSelectedAgents(prev => prev.filter(uuid => uuid !== agentUuid));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const finalCompany = adminRole === "COMPANY_ADMIN" ? adminCompany : companyName;
|
||||
|
||||
const successMsg = await submitExternalAccountForm({
|
||||
user,
|
||||
username,
|
||||
password,
|
||||
accountName,
|
||||
role,
|
||||
selectedAgents,
|
||||
finalCompany,
|
||||
picFile,
|
||||
});
|
||||
|
||||
setSuccess(successMsg);
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!user) return;
|
||||
if (!confirm(`Delete account "${user.username}"? This action cannot be undone.`)) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAdminAgentUser(user.id);
|
||||
setSuccess("Account successfully deleted!");
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const availableAgents = adminRole === "COMPANY_ADMIN" ? adminAgentUuids : globalAgents;
|
||||
|
||||
return {
|
||||
adminRole,
|
||||
adminCompany,
|
||||
username,
|
||||
setUsername,
|
||||
password,
|
||||
setPassword,
|
||||
accountName,
|
||||
setAccountName,
|
||||
role,
|
||||
setRole,
|
||||
companyName,
|
||||
setCompanyName,
|
||||
selectedAgents,
|
||||
previewPic,
|
||||
setPreviewPic,
|
||||
isSubmitting,
|
||||
isDeleting,
|
||||
success,
|
||||
error,
|
||||
setError,
|
||||
availableAgents,
|
||||
agentNamesMap,
|
||||
handlePicChange,
|
||||
handleAgentCheck,
|
||||
handleSubmit,
|
||||
handleDelete,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { updateAdminAgentUser, createAdminExternalUser } from "@/lib/admin-api";
|
||||
|
||||
export async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitExternalAccountForm({
|
||||
user,
|
||||
username,
|
||||
password,
|
||||
accountName,
|
||||
role,
|
||||
selectedAgents,
|
||||
finalCompany,
|
||||
picFile,
|
||||
}: {
|
||||
user?: any;
|
||||
username: string;
|
||||
password?: string;
|
||||
accountName: string;
|
||||
role: string;
|
||||
selectedAgents: string[];
|
||||
finalCompany: string;
|
||||
picFile: File | null;
|
||||
}) {
|
||||
const isEdit = !!user;
|
||||
if (isEdit) {
|
||||
const payload: Record<string, any> = {
|
||||
user_id: String(user.id),
|
||||
account_name: accountName.trim(),
|
||||
agent_uuids: selectedAgents,
|
||||
};
|
||||
if (username.trim()) payload.username = username.trim();
|
||||
if (password) payload.password = password;
|
||||
if (finalCompany) payload.company_name = finalCompany;
|
||||
|
||||
await updateAdminAgentUser(payload);
|
||||
|
||||
if (picFile) {
|
||||
const base64 = await fileToBase64(picFile);
|
||||
await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_picture_base64: base64, user_id: String(user.id) }),
|
||||
});
|
||||
}
|
||||
|
||||
return password ? "Password successfully reset & Account updated!" : "Account successfully updated!";
|
||||
} else {
|
||||
const payload: Record<string, any> = {
|
||||
role,
|
||||
agent_uuids: selectedAgents,
|
||||
};
|
||||
if (username.trim()) payload.username = username.trim();
|
||||
if (password) payload.password = password;
|
||||
if (accountName.trim()) payload.account_name = accountName.trim();
|
||||
if (finalCompany) payload.company_name = finalCompany;
|
||||
|
||||
const createResult = await createAdminExternalUser(payload);
|
||||
|
||||
if (picFile && createResult.userId) {
|
||||
const base64 = await fileToBase64(picFile);
|
||||
await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_picture_base64: base64, user_id: createResult.userId }),
|
||||
});
|
||||
}
|
||||
|
||||
return "External account successfully created!";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { MapPin, Info, Loader2 } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { MAP_CSS_INJECT, type FlowDetail } from "./GlobalAgentMapHelpers";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
import { UnregisteredAgentsPanel } from "./UnregisteredAgentsPanel";
|
||||
import { bootMapInstance } from "./GlobalAgentMapBoot";
|
||||
|
||||
interface LocationData {
|
||||
agent_uuid: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface FlowData {
|
||||
source: string;
|
||||
target: string;
|
||||
bytes: number;
|
||||
flowsCount: number;
|
||||
details: FlowDetail[];
|
||||
}
|
||||
|
||||
interface GlobalAgentMapProps {
|
||||
agents: Agent[];
|
||||
locations: LocationData[];
|
||||
onEditLocation: (agentUuid: string, label: string) => void;
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export function GlobalAgentMap({
|
||||
agents,
|
||||
locations,
|
||||
onEditLocation,
|
||||
role,
|
||||
}: GlobalAgentMapProps) {
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [loadingFlows, setLoadingFlows] = useState(true);
|
||||
const [flows, setFlows] = useState<FlowData[]>([]);
|
||||
const [mapError] = useState(false);
|
||||
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const layerGroupRef = useRef<any>(null);
|
||||
const tileLayerRef = useRef<any>(null);
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme !== "light";
|
||||
|
||||
const unregisteredAgents = useMemo(() => {
|
||||
const registeredUuids = new Set(locations.map(l => l.agent_uuid));
|
||||
return agents.filter(a => {
|
||||
const uuid = a.uuid || a.serial || "";
|
||||
return uuid && !registeredUuids.has(uuid);
|
||||
});
|
||||
}, [agents, locations]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingFlows(true);
|
||||
fetch("/api/dashboard/agent-flows", { headers: getViewAsHeaders() })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.ok && data.data) {
|
||||
setFlows(data.data);
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error("[Map Flows Fetch]", e.message))
|
||||
.finally(() => setLoadingFlows(false));
|
||||
}, [locations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
const triggerInvalidate = () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.invalidateSize();
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(triggerInvalidate, 150);
|
||||
window.addEventListener("resize", triggerInvalidate);
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
if (mapContainerRef.current) {
|
||||
observer = new ResizeObserver(() => triggerInvalidate());
|
||||
observer.observe(mapContainerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
window.removeEventListener("resize", triggerInvalidate);
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
}, [agents, locations, flows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
let isMounted = true;
|
||||
|
||||
bootMapInstance({
|
||||
mounted: () => isMounted,
|
||||
mapContainer: mapContainerRef.current!,
|
||||
agents,
|
||||
locations,
|
||||
flows,
|
||||
isDark,
|
||||
onEditLocation,
|
||||
mapInstanceRef,
|
||||
tileLayerRef,
|
||||
layerGroupRef,
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (mapInstanceRef.current) {
|
||||
try { mapInstanceRef.current.remove(); } catch (_) {}
|
||||
mapInstanceRef.current = null;
|
||||
layerGroupRef.current = null;
|
||||
tileLayerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [agents, locations, flows, isDark]);
|
||||
|
||||
if (mapError) {
|
||||
return (
|
||||
<div className={`flex h-[320px] w-full flex-col items-center justify-center rounded-2xl border p-6 text-center text-red-400 ${
|
||||
isDark ? "bg-slate-900/50 border-white/5" : "bg-white/50 border-slate-200"
|
||||
}`}>
|
||||
<Info className="mb-2 h-8 w-8" />
|
||||
<p className="text-sm font-semibold">Map failed to initialize.</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Please reload the page.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative w-full rounded-2xl border p-4 shadow-md transition-all duration-300 ${
|
||||
isDark
|
||||
? "bg-slate-900/40 border-white/5 shadow-black/40 backdrop-blur-xl"
|
||||
: "bg-white border-slate-200 shadow-slate-100"
|
||||
}`}>
|
||||
<style dangerouslySetInnerHTML={{ __html: MAP_CSS_INJECT }} />
|
||||
|
||||
<div className="mb-3.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className={`h-4 w-4 ${isDark ? "text-primary" : "text-blue-600"}`} />
|
||||
<div>
|
||||
<h3 className={`text-xs font-bold uppercase tracking-wider ${
|
||||
isDark ? "text-white" : "text-slate-800"
|
||||
}`}>
|
||||
Agent Location & Topology Map
|
||||
</h3>
|
||||
<p className={`text-[10px] ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
Physical deployment and real-time inter-agent routing flows globally.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingFlows && (
|
||||
<div className={`flex items-center gap-1.5 text-[10px] ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
<Loader2 className="h-3 w-3 animate-spin text-primary" />
|
||||
<span>Updating flows...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`relative overflow-hidden rounded-xl border ${
|
||||
isDark ? "border-white/5" : "border-slate-200"
|
||||
}`}>
|
||||
<div
|
||||
ref={mapContainerRef}
|
||||
className="h-[320px] w-full"
|
||||
id="global-agent-map"
|
||||
style={{ background: isDark ? "#090d16" : "#e8edf2" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UnregisteredAgentsPanel
|
||||
unregisteredAgents={unregisteredAgents}
|
||||
onEditLocation={onEditLocation}
|
||||
role={role}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
import { getFlowTooltipContent, getAgentTooltipContent, type FlowDetail } from "./GlobalAgentMapHelpers";
|
||||
|
||||
interface LocationData {
|
||||
agent_uuid: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface FlowData {
|
||||
source: string;
|
||||
target: string;
|
||||
bytes: number;
|
||||
flowsCount: number;
|
||||
details: FlowDetail[];
|
||||
}
|
||||
|
||||
export function buildMarkerHtml(online: boolean): string {
|
||||
return `
|
||||
<div class="custom-agent-marker ${online ? 'online' : 'offline'}">
|
||||
<div class="marker-beacon-ring"></div>
|
||||
<div class="marker-glass-disc">
|
||||
<div class="marker-core-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function bootMapInstance({
|
||||
mounted,
|
||||
mapContainer,
|
||||
agents,
|
||||
locations,
|
||||
flows,
|
||||
isDark,
|
||||
onEditLocation,
|
||||
mapInstanceRef,
|
||||
tileLayerRef,
|
||||
layerGroupRef,
|
||||
}: {
|
||||
mounted: () => boolean;
|
||||
mapContainer: HTMLDivElement;
|
||||
agents: Agent[];
|
||||
locations: LocationData[];
|
||||
flows: FlowData[];
|
||||
isDark: boolean;
|
||||
onEditLocation: (agentUuid: string, label: string) => void;
|
||||
mapInstanceRef: React.MutableRefObject<any>;
|
||||
tileLayerRef: React.MutableRefObject<any>;
|
||||
layerGroupRef: React.MutableRefObject<any>;
|
||||
}) {
|
||||
if (!mounted()) return;
|
||||
const L = (await import("leaflet")).default;
|
||||
if (!mounted() || !mapContainer) return;
|
||||
|
||||
if (!document.getElementById("lf-css-dpi")) {
|
||||
const lnk = document.createElement("link");
|
||||
lnk.id = "lf-css-dpi"; lnk.rel = "stylesheet";
|
||||
lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(lnk);
|
||||
}
|
||||
|
||||
const agentMap: Record<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {};
|
||||
agents.forEach((agent) => {
|
||||
const uuid = agent.uuid || agent.serial || "";
|
||||
const loc = locations.find((l) => l.agent_uuid === uuid);
|
||||
if (loc) {
|
||||
agentMap[uuid] = {
|
||||
lat: loc.latitude,
|
||||
lng: loc.longitude,
|
||||
label: loc.label || "",
|
||||
agentLabel: agent.label || "",
|
||||
status: agent.activated ? "Online" : "Offline",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const activeCoords = Object.values(agentMap);
|
||||
// Default to global view
|
||||
const centerLat = 20;
|
||||
const centerLng = 0;
|
||||
|
||||
const tileUrl = isDark
|
||||
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
|
||||
|
||||
if (!mapInstanceRef.current) {
|
||||
const map = L.map(mapContainer, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
zoomAnimation: false,
|
||||
fadeAnimation: false,
|
||||
markerZoomAnimation: false,
|
||||
}).setView([centerLat, centerLng], 2, { animate: false }); // Zoom 2 for global view
|
||||
|
||||
const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map);
|
||||
tileLayerRef.current = tiles;
|
||||
L.control.zoom({ position: "bottomright" }).addTo(map);
|
||||
mapInstanceRef.current = map;
|
||||
layerGroupRef.current = L.layerGroup().addTo(map);
|
||||
} else {
|
||||
const map = mapInstanceRef.current;
|
||||
if (tileLayerRef.current) tileLayerRef.current.setUrl(tileUrl);
|
||||
// Don't auto-pan, let the user pan the global map freely
|
||||
}
|
||||
|
||||
if (!mounted()) return;
|
||||
const group = layerGroupRef.current;
|
||||
group.clearLayers();
|
||||
const registeredUuids = new Set(locations.map(l => l.agent_uuid));
|
||||
|
||||
// Line traffic is intentionally removed from the Agents map per user request.
|
||||
// It is now exclusively displayed on the Dashboard Overview Map.
|
||||
|
||||
Object.entries(agentMap).forEach(([uuid, info]) => {
|
||||
if (!mounted()) return;
|
||||
const isOnline = info.status === "Online";
|
||||
const labelColor = isOnline ? "#34d399" : "#f87171";
|
||||
const borderCol = isOnline ? "#10b981" : "#ef4444";
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: buildMarkerHtml(isOnline),
|
||||
className: "",
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
tooltipAnchor: [0, -18],
|
||||
popupAnchor: [0, -18],
|
||||
});
|
||||
|
||||
const marker = L.marker([info.lat, info.lng], { icon });
|
||||
|
||||
const shortLabel = info.agentLabel.length > 24 ? info.agentLabel.slice(0, 22) + "…" : info.agentLabel;
|
||||
marker.bindTooltip(
|
||||
`<div class="dpi-agent-label" style="background:rgba(10,18,36,0.92);color:${labelColor};border-color:${borderCol};box-shadow:0 0 10px ${borderCol}44,0 2px 8px rgba(0,0,0,0.7);">` +
|
||||
`<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${borderCol};margin-right:5px;${isOnline?`box-shadow:0 0 6px ${borderCol}`:""}"></span>` +
|
||||
`${shortLabel}</div>`,
|
||||
{ permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false }
|
||||
);
|
||||
|
||||
const popupHtml = getAgentTooltipContent(
|
||||
uuid, info.agentLabel, info.status, isOnline, info.label, info.lat, info.lng
|
||||
);
|
||||
marker.bindPopup(popupHtml, { closeButton: false, className: "leaflet-custom-popup", offset: [0, -8] });
|
||||
marker.on("popupopen", () => {
|
||||
const btn = document.getElementById(`btn-edit-${uuid}`);
|
||||
if (btn) btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel));
|
||||
});
|
||||
|
||||
marker.addTo(group);
|
||||
});
|
||||
|
||||
if (activeCoords.length > 1 && mounted()) {
|
||||
const L2 = L as any;
|
||||
const bounds = L2.latLngBounds(activeCoords.map((c: any) => [c.lat, c.lng]));
|
||||
mapInstanceRef.current.fitBounds(bounds, { padding: [60, 60], maxZoom: 14, animate: false });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
export interface FlowDetail {
|
||||
src_ip: string;
|
||||
dst_ip: string;
|
||||
app: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function getFlowTooltipContent(
|
||||
srcLabel: string,
|
||||
dstLabel: string,
|
||||
totalBytes: number,
|
||||
flowsCount: number,
|
||||
details: FlowDetail[]
|
||||
): string {
|
||||
const mb = (totalBytes / 1024 / 1024).toFixed(2);
|
||||
let html = `
|
||||
<div style="padding: 12px 16px; font-family: inherit; font-size: 13px; font-weight: 500; color: #fff; background: rgba(15, 23, 42, 0.95); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); min-width: 280px; pointer-events: none;">
|
||||
<div style="font-weight: 600; font-size: 14px; margin-bottom: 6px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255,255,255,0.15); padding-bottom: 6px; color: #38bdf8;">
|
||||
<span>Inter-Agent Link</span>
|
||||
<span style="color: #facc15; font-family: monospace;">${mb} MB</span>
|
||||
</div>
|
||||
<div style="color: #cbd5e1; font-size: 11px; margin-bottom: 10px; font-weight: normal;">
|
||||
${srcLabel} ➔ ${dstLabel} (${flowsCount} connections)
|
||||
</div>
|
||||
<div style="margin-top: 6px;">
|
||||
<p style="font-size: 9px; text-transform: uppercase; font-weight: 700; color: #94a3b8; margin-bottom: 6px; margin-top: 0; letter-spacing: 0.05em;">Top Active Flows:</p>
|
||||
<table style="width: 100%; text-align: left; font-family: monospace; font-size: 11px; color: #cbd5e1; border-collapse: collapse;">
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
if (details && details.length > 0) {
|
||||
details.forEach(d => {
|
||||
html += `
|
||||
<tr>
|
||||
<td style="padding: 3px 6px 3px 0; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #f1f5f9;" title="${d.src_ip}">${d.src_ip}</td>
|
||||
<td style="padding: 3px 6px; color: #64748b;">➔</td>
|
||||
<td style="padding: 3px 6px; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #f1f5f9;" title="${d.dst_ip}">${d.dst_ip}</td>
|
||||
<td style="padding: 3px 6px; color: #38bdf8;">${d.app}</td>
|
||||
<td style="padding: 3px 0 3px 6px; text-align: right; color: #4ade80; font-weight: 600;">${formatBytes(d.bytes)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
} else {
|
||||
html += `<tr><td colspan="5" style="color: #64748b; font-style: italic; text-align: center; padding: 6px 0;">No detailed flow data</td></tr>`;
|
||||
}
|
||||
|
||||
html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return html;
|
||||
}
|
||||
|
||||
export function getAgentTooltipContent(
|
||||
uuid: string,
|
||||
agentLabel: string,
|
||||
status: string,
|
||||
isOnline: boolean,
|
||||
customLabel: string,
|
||||
lat: number,
|
||||
lng: number
|
||||
): string {
|
||||
return `
|
||||
<div style="padding: 12px 16px; font-family: inherit; font-size: 13px; font-weight: 500; color: #fff; background: rgba(15, 23, 42, 0.95); border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); min-width: 200px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.15); padding-bottom: 6px;">
|
||||
<span style="font-weight: 600; font-size: 14px; color: #f8fafc;">${agentLabel}</span>
|
||||
<span style="padding: 2px 6px; border-radius: 4px; font-size: 9px; font-weight: bold; ${
|
||||
isOnline ? "background: rgba(34,197,94,0.2); color: #4ade80;" : "background: rgba(239,68,68,0.2); color: #f87171;"
|
||||
}">${status}</span>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: #cbd5e1; line-height: 1.5; font-weight: normal;">
|
||||
<p style="margin: 3px 0;"><span style="color: #64748b; font-weight: 500;">UUID:</span> ${uuid}</p>
|
||||
<p style="margin: 3px 0;"><span style="color: #64748b; font-weight: 500;">Location:</span> ${customLabel || "No custom label set"}</p>
|
||||
<p style="margin: 3px 0;"><span style="color: #64748b; font-weight: 500;">Coord:</span> ${lat.toFixed(5)}, ${lng.toFixed(5)}</p>
|
||||
</div>
|
||||
<button
|
||||
id="btn-edit-${uuid}"
|
||||
style="margin-top: 10px; width: 100%; background-color: #3b82f6; color: #fff; border: none; font-size: 11px; font-weight: 600; padding: 6px 10px; border-radius: 6px; cursor: pointer; text-align: center; transition: background-color 0.2s;"
|
||||
onmouseover="this.style.backgroundColor='#2563eb'"
|
||||
onmouseout="this.style.backgroundColor='#3b82f6'"
|
||||
>
|
||||
Configure Geolocation
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export const MAP_CSS_INJECT = `
|
||||
@keyframes flowDash {
|
||||
to {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
.animated-flow-line {
|
||||
animation: flowDash 1.5s linear infinite;
|
||||
}
|
||||
.leaflet-container {
|
||||
border-radius: 12px;
|
||||
font-family: inherit;
|
||||
transition: background 0.3s, border 0.3s;
|
||||
}
|
||||
.leaflet-custom-popup .leaflet-popup-content-wrapper {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.leaflet-custom-popup .leaflet-popup-tip {
|
||||
background: #0f172a !important;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
}
|
||||
/* Light mode popup tip — matches white popup background */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.leaflet-custom-popup .leaflet-popup-tip {
|
||||
background: #ffffff !important;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
}
|
||||
.leaflet-custom-popup .leaflet-popup-content {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
/* Agent label tooltips (permanent, below each circleMarker) */
|
||||
.agent-label-tooltip {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.agent-label-tooltip::before {
|
||||
display: none !important;
|
||||
}
|
||||
.agent-map-label {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
|
||||
}
|
||||
/* Zero-out default leaflet div icon styling (kept for compatibility) */
|
||||
.custom-div-icon {
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Download, Upload, Server, Activity, ShieldAlert, Zap } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface KPICardsProps {
|
||||
summary: {
|
||||
bandwidth_down: number;
|
||||
bandwidth_up: number;
|
||||
total_devices: number;
|
||||
active_flows: number;
|
||||
total_threats: number;
|
||||
total_events: number;
|
||||
download_speed?: number;
|
||||
upload_speed?: number;
|
||||
flow_speed?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function KPICards({ summary }: KPICardsProps) {
|
||||
// Use average speed over 24 hours (86400 seconds) to match standard BackOne analytics.
|
||||
const SECONDS_IN_24H = 86400;
|
||||
const downloadSpeedBits = summary ? (summary.bandwidth_down * 8) / SECONDS_IN_24H : 0;
|
||||
const uploadSpeedBits = summary ? (summary.bandwidth_up * 8) / SECONDS_IN_24H : 0;
|
||||
const flowsPerHour = summary ? Math.floor(summary.active_flows / 24) : 0;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-blue-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '0ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Download className="h-16 w-16 text-blue-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-blue-400 transition-colors">Download</CardTitle>
|
||||
<Download className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary ? fmtBytes(summary.bandwidth_down) : "0 B"}
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-blue-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
Speed: {downloadSpeedBits >= 1e6
|
||||
? `${(downloadSpeedBits / 1e6).toFixed(1)} Mbps`
|
||||
: downloadSpeedBits >= 1e3
|
||||
? `${(downloadSpeedBits / 1e3).toFixed(1)} Kbps`
|
||||
: `${downloadSpeedBits.toFixed(1)} bps`}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-emerald-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-emerald-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '50ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Upload className="h-16 w-16 text-emerald-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-emerald-400 transition-colors">Upload</CardTitle>
|
||||
<Upload className="h-4 w-4 text-emerald-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary ? fmtBytes(summary.bandwidth_up) : "0 B"}
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-emerald-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
Speed: {uploadSpeedBits >= 1e6
|
||||
? `${(uploadSpeedBits / 1e6).toFixed(1)} Mbps`
|
||||
: uploadSpeedBits >= 1e3
|
||||
? `${(uploadSpeedBits / 1e3).toFixed(1)} Kbps`
|
||||
: `${uploadSpeedBits.toFixed(1)} bps`}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/devices" className="block h-full focus:outline-none focus:ring-2 focus:ring-orange-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-orange-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Server className="h-16 w-16 text-orange-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-orange-400 transition-colors">Devices</CardTitle>
|
||||
<Server className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary?.total_devices?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-purple-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-purple-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '150ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Activity className="h-16 w-16 text-purple-500" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-purple-400 transition-colors">Flows</CardTitle>
|
||||
<Activity className="h-4 w-4 text-purple-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary?.active_flows?.toLocaleString() || "0"}
|
||||
</div>
|
||||
{summary && flowsPerHour > 0 ? (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-purple-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse" />
|
||||
Speed: {flowsPerHour.toLocaleString()} /hr
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/threats" className="block h-full focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-red-50/90 dark:bg-card/80 backdrop-blur-xl border-red-200 dark:border-red-500/20 hover:bg-red-100 hover:dark:bg-card/100 hover:border-red-300 hover:dark:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className="absolute inset-0 bg-red-500/10 dark:bg-red-500/5 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 p-4 opacity-20 dark:opacity-10 group-hover:opacity-30 dark:group-hover:opacity-20 transition-opacity text-red-600 dark:text-red-500">
|
||||
<ShieldAlert className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-semibold text-red-700 dark:text-red-400 group-hover:text-red-800 dark:group-hover:text-red-300 transition-colors">Threats</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-500 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-red-600 dark:text-red-500 tracking-tight whitespace-nowrap">
|
||||
{summary?.total_threats?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
<Link href="/events" className="block h-full focus:outline-none focus:ring-2 focus:ring-yellow-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-yellow-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '250ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-yellow-500">
|
||||
<Zap className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-yellow-400 transition-colors">Events</CardTitle>
|
||||
<Zap className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-white tracking-tight whitespace-nowrap">
|
||||
{summary?.total_events?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import {
|
||||
ResponsiveContainer, BarChart, Bar, CartesianGrid, XAxis, YAxis,
|
||||
Tooltip as RechartsTooltip, PieChart, Pie, Cell
|
||||
} from "recharts";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
|
||||
interface TopWidgetsProps {
|
||||
topApps: {
|
||||
data: { app_label: string; total_bytes: number }[];
|
||||
};
|
||||
topProtocols: {
|
||||
data: { protocol_label: string; download: number }[];
|
||||
};
|
||||
}
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#3b82f6'];
|
||||
|
||||
export function TopWidgets({ topApps, topProtocols }: TopWidgetsProps) {
|
||||
return (
|
||||
<div className="col-span-1 lg:col-span-3 space-y-6 w-full min-w-0">
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1 min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white">Top Apps (Total Bandwidth)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full min-w-0">
|
||||
{(!topApps.data || topApps.data.length === 0) ? (
|
||||
<div className="h-[150px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No Data
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[150px] w-full min-w-0 relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topApps.data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="app_label"
|
||||
stroke="#a1a1aa"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={5}
|
||||
tickFormatter={(val) => {
|
||||
const formatted = formatAppLabel(val);
|
||||
return formatted.length > 12 ? `${formatted.substring(0, 10)}...` : formatted;
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#a1a1aa"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
if (value === 0) return '0';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(value) / Math.log(k));
|
||||
const scaled = parseFloat((value / Math.pow(k, i)).toFixed(1));
|
||||
return `${scaled}${sizes[i].charAt(0)}`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(24, 24, 27, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: '#fafafa',
|
||||
}}
|
||||
itemStyle={{ color: '#fafafa', fontSize: '13px', fontWeight: 500 }}
|
||||
formatter={(value: any) => [fmtBytes(value as number), 'Total Bandwidth']}
|
||||
/>
|
||||
<Bar dataKey="total_bytes" fill="#8b5cf6" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1 min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white">Top Protocols</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="w-full min-w-0">
|
||||
{(!topProtocols.data || topProtocols.data.length === 0) ? (
|
||||
<div className="h-[150px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No Data
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[150px] w-full min-w-0 relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topProtocols.data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="protocol_label"
|
||||
stroke="#a1a1aa"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={5}
|
||||
tickFormatter={(val) => {
|
||||
const formatted = val || '';
|
||||
return formatted.length > 12 ? `${formatted.substring(0, 10)}...` : formatted;
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#a1a1aa"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
if (value === 0) return '0';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
const i = Math.floor(Math.log(value) / Math.log(k));
|
||||
const scaled = parseFloat((value / Math.pow(k, i)).toFixed(1));
|
||||
return `${scaled}${sizes[i].charAt(0)}`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
cursor={{fill: 'rgba(255,255,255,0.05)'}}
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(24, 24, 27, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: '#fafafa',
|
||||
}}
|
||||
itemStyle={{ color: '#fafafa', fontSize: '13px', fontWeight: 500 }}
|
||||
formatter={(value: any) => [fmtBytes(value as number), 'Total Traffic']}
|
||||
/>
|
||||
<Bar dataKey="total" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { MapPin, AlertTriangle, ChevronDown, ChevronUp, Plus } from "lucide-react";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
|
||||
interface UnregisteredAgentsPanelProps {
|
||||
unregisteredAgents: Agent[];
|
||||
onEditLocation: (agentUuid: string, label: string) => void;
|
||||
role?: string | null;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
export function UnregisteredAgentsPanel({
|
||||
unregisteredAgents,
|
||||
onEditLocation,
|
||||
role,
|
||||
isDark,
|
||||
}: UnregisteredAgentsPanelProps) {
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
|
||||
if (unregisteredAgents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`mt-3 rounded-xl border transition-colors duration-200 ${
|
||||
isDark
|
||||
? "border-amber-500/20 bg-amber-500/5"
|
||||
: "border-blue-200 bg-blue-50"
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => setPanelOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${isDark ? "text-amber-500" : "text-blue-500"}`} />
|
||||
<span className={`text-xs font-semibold ${
|
||||
isDark ? "text-amber-400" : "text-blue-700"
|
||||
}`}>
|
||||
{unregisteredAgents.length} Agent{unregisteredAgents.length > 1 ? "s" : ""} Without Coordinates{role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN' ? " — Click to Configure" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{panelOpen
|
||||
? <ChevronUp className={`h-3.5 w-3.5 ${isDark ? "text-amber-500" : "text-blue-400"}`} />
|
||||
: <ChevronDown className={`h-3.5 w-3.5 ${isDark ? "text-amber-500" : "text-blue-400"}`} />}
|
||||
</button>
|
||||
|
||||
{panelOpen && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-3 pt-0.5">
|
||||
{unregisteredAgents.map(agent => {
|
||||
const uuid = agent.uuid || agent.serial || "";
|
||||
const isOnline = agent.activated;
|
||||
const isWritable = role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN';
|
||||
return (
|
||||
<button
|
||||
key={uuid}
|
||||
onClick={isWritable ? () => onEditLocation(uuid, agent.label || uuid) : undefined}
|
||||
disabled={!isWritable}
|
||||
className={`group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
isDark
|
||||
? isWritable
|
||||
? "border-white/10 bg-slate-900/60 text-slate-300 hover:border-primary/40 hover:bg-primary/10 hover:text-primary cursor-pointer"
|
||||
: "border-white/5 bg-slate-900/40 text-slate-500 cursor-default"
|
||||
: isWritable
|
||||
? "border-blue-200 bg-white text-slate-700 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700 cursor-pointer"
|
||||
: "border-slate-200 bg-slate-50 text-slate-400 cursor-default"
|
||||
}`}
|
||||
>
|
||||
<MapPin className={`h-3.5 w-3.5 shrink-0 ${
|
||||
isOnline ? "text-emerald-500 animate-pulse" : "text-red-400"
|
||||
}`} />
|
||||
<span className="max-w-[160px] truncate" title={agent.label}>{agent.label}</span>
|
||||
<span className={`font-mono text-[10px] truncate max-w-[80px] ${
|
||||
isDark ? "text-slate-500" : "text-slate-400"
|
||||
}`} title={uuid}>{uuid}</span>
|
||||
{isWritable && (
|
||||
<Plus className={`h-3 w-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity ${
|
||||
isDark ? "" : "text-blue-500"
|
||||
}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Loader2, Database, AlertCircle, Globe, Monitor, Cpu, Fingerprint, Hash, Terminal, Radio } from "lucide-react";
|
||||
import { useMetadataDetail, MetadataDetailType } from "@/lib/api-metadata-detail";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
|
||||
import { COLS, getColGroup, TableHead, TableRow } from "./MetadataDetailTable";
|
||||
|
||||
interface TypeConfig {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: React.ElementType;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<MetadataDetailType, TypeConfig> = {
|
||||
sni_hostname: { title: "SNI Hostname", subtitle: "Devices that accessed this TLS hostname", icon: Globe, color: "blue" },
|
||||
ssl_server_cn: { title: "SSL Common Name", subtitle: "Devices that connected to this certificate", icon: Globe, color: "blue" },
|
||||
quic_hostname: { title: "QUIC Hostname", subtitle: "Devices using this QUIC destination", icon: Globe, color: "purple" },
|
||||
netbios_hostname:{ title: "NetBIOS Hostname", subtitle: "Device records with this NetBIOS name", icon: Monitor, color: "cyan" },
|
||||
os_label: { title: "Operating System", subtitle: "Devices running this operating system", icon: Cpu, color: "emerald" },
|
||||
dhcp_fingerprint:{ title: "DHCP Fingerprint", subtitle: "Agents with this device fingerprint class", icon: Fingerprint, color: "amber" },
|
||||
http_useragent: { title: "HTTP User Agent", subtitle: "Agent records for this User Agent string", icon: Globe, color: "orange" },
|
||||
bittorrent_hash: { title: "BitTorrent Hash", subtitle: "Agent records for this torrent info hash", icon: Hash, color: "red" },
|
||||
ssh_version: { title: "SSH Version", subtitle: "SSH client and server connections", icon: Terminal, color: "violet" },
|
||||
mdns_hostname: { title: "mDNS Hostname", subtitle: "Agent records for this mDNS announcement", icon: Radio, color: "teal" },
|
||||
};
|
||||
|
||||
const COLOR_MAP: Record<string, { icon: string; ring: string; glow: string }> = {
|
||||
blue: { icon: "text-blue-400", ring: "ring-blue-500/30", glow: "bg-blue-500" },
|
||||
purple: { icon: "text-purple-400", ring: "ring-purple-500/30", glow: "bg-purple-500" },
|
||||
cyan: { icon: "text-cyan-400", ring: "ring-cyan-500/30", glow: "bg-cyan-500" },
|
||||
emerald:{ icon: "text-emerald-400", ring: "ring-emerald-500/30", glow: "bg-emerald-500" },
|
||||
amber: { icon: "text-amber-400", ring: "ring-amber-500/30", glow: "bg-amber-500" },
|
||||
orange: { icon: "text-orange-400", ring: "ring-orange-500/30", glow: "bg-orange-500" },
|
||||
red: { icon: "text-red-400", ring: "ring-red-500/30", glow: "bg-red-500" },
|
||||
violet: { icon: "text-violet-400", ring: "ring-violet-500/30", glow: "bg-violet-500" },
|
||||
teal: { icon: "text-teal-400", ring: "ring-teal-500/30", glow: "bg-teal-500" },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
type: MetadataDetailType | null;
|
||||
value: string | null;
|
||||
timeRange: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props) {
|
||||
const isOpen = !!(type && value);
|
||||
const { data, isLoading, error } = useMetadataDetail(type, value, timeRange);
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => { setIsMounted(true); }, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setVisible(false);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "unset";
|
||||
onClose();
|
||||
}, 480);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setVisible(true);
|
||||
setIsClosing(false);
|
||||
setPage(1);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") handleClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => () => { document.body.style.overflow = "unset"; }, []);
|
||||
|
||||
if (!isMounted || !visible || !type || !value) return null;
|
||||
|
||||
const cfg = TYPE_CONFIG[type];
|
||||
const colors = COLOR_MAP[cfg.color];
|
||||
const Icon = cfg.icon;
|
||||
const group = getColGroup(type);
|
||||
const cols = COLS[group];
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(data.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const pageStart = (safePage - 1) * PAGE_SIZE;
|
||||
const pagedData = data.slice(pageStart, pageStart + PAGE_SIZE);
|
||||
|
||||
const totalDownload = data.reduce((s, r) => s + (r.download || 0), 0);
|
||||
const totalUpload = data.reduce((s, r) => s + (r.upload || 0), 0);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/60 backdrop-blur-md ${isClosing ? "animate-overlay-out" : "animate-overlay-in"}`} />
|
||||
|
||||
<div
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 overflow-hidden ${isClosing ? "animate-modal-out" : "animate-modal-in"}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-blue-900/30 to-purple-900/20 rounded-t-2xl flex-shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className={`absolute inset-0 ${colors.glow} blur-xl opacity-20 rounded-full`} />
|
||||
<div className={`p-2.5 bg-white/[0.06] rounded-xl ring-1 ${colors.ring} relative`}>
|
||||
<Icon className={`w-5 h-5 ${colors.icon}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className={`text-xs font-semibold uppercase tracking-widest mb-0.5 ${colors.icon}`}>{cfg.title}</p>
|
||||
<h3 className="text-lg font-bold text-card-foreground truncate" title={value}>{value}</h3>
|
||||
{getKnownDomainService(value) && (
|
||||
<span className="inline-block mt-1 px-2 py-0.5 bg-indigo-500/20 text-indigo-300 text-[10px] font-semibold rounded uppercase tracking-wider">
|
||||
{getKnownDomainService(value)}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{cfg.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="ml-4 flex-shrink-0 p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isLoading && data.length > 0 && (
|
||||
<div className="flex items-center gap-6 px-6 py-2.5 bg-white/[0.02] border-b border-white/[0.06] flex-shrink-0 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
<span className="font-semibold text-slate-200">{data.length.toLocaleString()}</span>
|
||||
{" "}{data.length === 1 ? "record" : "records"}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Total Download:{" "}
|
||||
<span className="text-cyan-400 font-semibold font-mono">{fmtBytes(totalDownload)}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Total Upload:{" "}
|
||||
<span className="text-emerald-400 font-semibold font-mono">{fmtBytes(totalUpload)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-400" />
|
||||
<p className="text-slate-400 text-sm font-medium">Loading data from Database...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-red-400">
|
||||
<AlertCircle className="h-8 w-8" />
|
||||
<p className="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 text-slate-500">
|
||||
<Database className="h-8 w-8" />
|
||||
<p className="text-base font-semibold text-slate-400">No Data</p>
|
||||
<p className="text-sm">No records found for this entry in Database</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-center whitespace-nowrap text-sm table-fixed">
|
||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||
<TableHead cols={cols} />
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{pagedData.map((row, i) => (
|
||||
<TableRow key={pageStart + i} row={row} group={group} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isLoading && !error && data.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<Pagination
|
||||
currentPage={safePage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={data.length}
|
||||
startIndex={pageStart}
|
||||
endIndex={pageStart + PAGE_SIZE}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { MetadataDetailRow, MetadataDetailType } from "@/lib/api-metadata-detail";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
export type ColDef = { header: string; width: string; align?: "center" };
|
||||
|
||||
export const COLS: Record<string, ColDef[]> = {
|
||||
domain: [
|
||||
{ header: "Source IP", width: "w-[12.5%]" },
|
||||
{ header: "MAC Address", width: "w-[12.5%]" },
|
||||
{ header: "Device Name", width: "w-[12.5%]" },
|
||||
{ header: "Manufacturer", width: "w-[12.5%]" },
|
||||
{ header: "OS", width: "w-[12.5%]" },
|
||||
{ header: "Download", width: "w-[12.5%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[12.5%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[12.5%]", align: "center" },
|
||||
],
|
||||
device: [
|
||||
{ header: "IP Address", width: "w-[16.6%]" },
|
||||
{ header: "MAC Address", width: "w-[16.6%]" },
|
||||
{ header: "Device Name", width: "w-[16.6%]" },
|
||||
{ header: "Manufacturer", width: "w-[16.6%]" },
|
||||
{ header: "Download", width: "w-[16.6%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[16.6%]", align: "center" },
|
||||
],
|
||||
torrent: [
|
||||
{ header: "Agent", width: "w-[20%]" },
|
||||
{ header: "Torrent Label", width: "w-[20%]" },
|
||||
{ header: "Download", width: "w-[20%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[20%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[20%]", align: "center" },
|
||||
],
|
||||
ssh: [
|
||||
{ header: "Role", width: "w-[20%]", align: "center" },
|
||||
{ header: "Agent", width: "w-[20%]" },
|
||||
{ header: "Download", width: "w-[20%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[20%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[20%]", align: "center" },
|
||||
],
|
||||
default: [
|
||||
{ header: "Agent", width: "w-[25%]" },
|
||||
{ header: "Download", width: "w-[25%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[25%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[25%]", align: "center" },
|
||||
],
|
||||
};
|
||||
|
||||
export function getColGroup(type: MetadataDetailType): string {
|
||||
if (["sni_hostname", "ssl_server_cn", "quic_hostname"].includes(type)) return "domain";
|
||||
if (["netbios_hostname", "os_label"].includes(type)) return "device";
|
||||
if (type === "bittorrent_hash") return "torrent";
|
||||
if (type === "ssh_version") return "ssh";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = true }: { value: string | number; className?: string; center?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
return (
|
||||
<div
|
||||
className={`truncate whitespace-nowrap overflow-hidden text-ellipsis ${center ? "text-center" : ""} ${className}`}
|
||||
title={str}
|
||||
>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHead({ cols }: { cols: ColDef[] }) {
|
||||
const base = "px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center";
|
||||
return (
|
||||
<tr>
|
||||
{cols.map((c) => (
|
||||
<th key={c.header} className={`${base} ${c.width}`}>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({ row, group }: { row: MetadataDetailRow; group: string }) {
|
||||
const tr = "border-b border-white/[0.04] hover:bg-white/[0.04] transition-colors duration-150";
|
||||
const tc = "px-3 py-2.5 text-sm text-center align-middle";
|
||||
|
||||
if (group === "domain") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.src_ip || "—"} className="text-blue-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.mac_address || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.device_label || row.src_ip || "—"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={row.manufacturer || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.os_label || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "device") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.ip_address || "—"} className="text-blue-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.mac_address || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.device_label || "—"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={row.manufacturer || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "torrent") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.label || "Unknown"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "ssh") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}>
|
||||
<div className="flex justify-center">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
row.role === "Client" ? "bg-blue-500/20 text-blue-300" : "bg-purple-500/20 text-purple-300"
|
||||
}`}>{row.role || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { X, BookOpen, ChevronUp, ChevronDown, Info, HelpCircle, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { PAGE_GUIDES, type PageGuide } from "@/lib/helpContent";
|
||||
|
||||
interface ContextualHelpModalProps {
|
||||
pageId: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SectionBlock({ heading, content, steps, columns, items }: PageGuide["sections"][0]) {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/8 bg-white/[0.02] overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between px-4 py-3 hover:bg-white/4 transition-colors text-left"
|
||||
>
|
||||
<span className="text-xs font-semibold text-white/90">{heading}</span>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-white/5">
|
||||
{open ? (
|
||||
<ChevronUp className="h-3 w-3 text-slate-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-slate-400" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-4 pb-4 pt-1 space-y-3 border-t border-white/5">
|
||||
{content && (
|
||||
<p className="text-xs text-slate-400 leading-relaxed">{content}</p>
|
||||
)}
|
||||
|
||||
{steps && steps.length > 0 && (
|
||||
<ol className="space-y-2">
|
||||
{steps.map((step, i) => (
|
||||
<li key={i} className="flex gap-2.5 items-start">
|
||||
<span className="shrink-0 flex h-4.5 w-4.5 min-w-[18px] items-center justify-center rounded-full bg-blue-600/30 border border-blue-500/40 text-[9px] font-bold text-blue-300 mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-xs text-slate-300 leading-relaxed">{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{columns && columns.length > 0 && (
|
||||
<div className="rounded-lg overflow-hidden border border-white/5">
|
||||
<div className="grid grid-cols-[140px_1fr] bg-white/[0.04] px-3 py-2 border-b border-white/5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-blue-400">Column</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-400">How to Read</span>
|
||||
</div>
|
||||
{columns.map((col, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`grid grid-cols-[140px_1fr] px-3 py-2 gap-3 border-b border-white/5 last:border-0 ${
|
||||
i % 2 === 0 ? "bg-white/[0.01]" : ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="font-mono text-xs font-semibold text-blue-400 truncate"
|
||||
title={col.column}
|
||||
>
|
||||
{col.column}
|
||||
</span>
|
||||
<span className="text-xs text-slate-300 leading-relaxed">
|
||||
{col.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items && items.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex gap-3 rounded-lg bg-white/[0.02] border border-white/5 px-3 py-2"
|
||||
>
|
||||
<span className="text-xs font-semibold text-blue-400 w-32 shrink-0 pt-0.5">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-xs text-slate-300 leading-relaxed">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContextualHelpModal({ pageId, isOpen, onClose }: ContextualHelpModalProps) {
|
||||
const guide = PAGE_GUIDES.find((p) => p.id === pageId);
|
||||
|
||||
// Esc key closes modal
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
if (isOpen) {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!guide) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
||||
{/* Backdrop blur overlay */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md"
|
||||
/>
|
||||
|
||||
{/* Modal content box */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 15 }}
|
||||
transition={{ type: "spring", duration: 0.4 }}
|
||||
className="relative w-full max-w-2xl max-h-[85vh] flex flex-col rounded-2xl border border-white/10 bg-slate-900/90 text-white shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between border-b border-white/6 px-6 py-4 bg-gradient-to-r from-blue-900/20 to-indigo-900/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-indigo-600/15 border border-blue-500/20 text-xl shadow-inner">
|
||||
{guide.icon}
|
||||
</span>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-bold text-white">Guide: {guide.title}</h3>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Metric columns and data layout guide</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-slate-400 hover:bg-white/5 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-4.5 w-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Body */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4">
|
||||
<div className="rounded-xl border border-blue-500/10 bg-blue-500/5 px-4 py-3 text-xs text-slate-300 leading-relaxed">
|
||||
<span className="font-semibold text-blue-400">About This Page:</span> {guide.summary}
|
||||
</div>
|
||||
|
||||
{guide.sections.map((section, idx) => (
|
||||
<SectionBlock key={idx} {...section} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-white/6 bg-white/[0.02] px-6 py-4 flex items-center justify-between">
|
||||
<span className="text-[10px] text-slate-500">
|
||||
Full detailed lookup is available in the main User Guide.
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-xl border border-white/8 bg-white/5 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white transition-all"
|
||||
>
|
||||
Close Guide
|
||||
</button>
|
||||
<Link
|
||||
href="/help"
|
||||
className="flex items-center gap-1 rounded-xl bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-md shadow-blue-600/10 hover:bg-blue-500 transition-all"
|
||||
>
|
||||
Open Full Guide
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
ChevronRight, Home, Radio, Shuffle, Package, Monitor,
|
||||
Globe2, BarChart3, Map, Shield, Bell, Lock, BookOpen,
|
||||
ChevronDown, ChevronUp, Info, Lightbulb, ArrowLeft,
|
||||
Activity, Plug, Globe, Search, Tag
|
||||
} from "lucide-react";
|
||||
import { PAGE_GUIDES, UI_CONVENTIONS, type PageGuide } from "@/lib/helpContent";
|
||||
|
||||
const ICON_MAP: Record<string, React.ReactNode> = {
|
||||
overview: <Home className="h-4 w-4" />,
|
||||
agents: <Radio className="h-4 w-4" />,
|
||||
flows: <Shuffle className="h-4 w-4" />,
|
||||
apps: <Package className="h-4 w-4" />,
|
||||
devices: <Monitor className="h-4 w-4" />,
|
||||
dns: <Globe2 className="h-4 w-4" />,
|
||||
"network-intelligence":<BarChart3 className="h-4 w-4" />,
|
||||
"dpi-analytics": <Activity className="h-4 w-4" />,
|
||||
geography: <Map className="h-4 w-4" />,
|
||||
intelligence: <Shield className="h-4 w-4" />,
|
||||
events: <Bell className="h-4 w-4" />,
|
||||
"security-audit": <Lock className="h-4 w-4" />,
|
||||
"network-infrastructure": <Globe className="h-4 w-4" />,
|
||||
lookup: <Search className="h-4 w-4" />,
|
||||
"device-labeling": <Tag className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
function SectionBlock({ heading, content, steps, columns, items }: PageGuide["sections"][0]) {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/8 bg-gradient-to-b from-slate-900/80 to-slate-900/40 overflow-hidden shadow-lg">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 hover:bg-white/4 transition-colors text-left group"
|
||||
>
|
||||
<span className="text-sm font-semibold text-white/90 group-hover:text-white transition-colors">{heading}</span>
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/5 group-hover:bg-white/10 transition-colors">
|
||||
{open ? <ChevronUp className="h-3.5 w-3.5 text-slate-400" /> : <ChevronDown className="h-3.5 w-3.5 text-slate-400" />}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-5 pb-5 pt-1 space-y-3 border-t border-white/5">
|
||||
{content && (
|
||||
<p className="text-sm text-slate-400 leading-relaxed">{content}</p>
|
||||
)}
|
||||
{steps && (
|
||||
<ol className="space-y-2">
|
||||
{steps.map((step, i) => (
|
||||
<li key={i} className="flex gap-3 items-start">
|
||||
<span className="shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-blue-600/30 border border-blue-500/40 text-[10px] font-bold text-blue-300 mt-0.5">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="text-sm text-slate-300 leading-relaxed">{step}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{columns && (
|
||||
<div className="rounded-xl overflow-hidden border border-white/8">
|
||||
<div className="grid grid-cols-[180px_1fr] bg-gradient-to-r from-blue-600/20 to-indigo-600/10 px-4 py-2.5 border-b border-white/8">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-blue-400">Column / Field</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">How to Read</span>
|
||||
</div>
|
||||
{columns.map((col, i) => (
|
||||
<div key={i} className={`grid grid-cols-[180px_1fr] px-4 py-3 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}>
|
||||
<span className="font-mono text-xs font-semibold text-blue-400 self-start pt-0.5 truncate" title={col.column}>{col.column}</span>
|
||||
<span className="text-xs text-slate-300 leading-relaxed">{col.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{items && (
|
||||
<div className="space-y-2">
|
||||
{items.map((item, i) => (
|
||||
<div key={i} className="flex gap-3 rounded-xl bg-white/[0.03] border border-white/6 px-4 py-3 hover:bg-white/[0.05] transition-colors">
|
||||
<span className="text-xs font-semibold text-blue-400 w-44 shrink-0 pt-0.5">{item.label}</span>
|
||||
<span className="text-xs text-slate-300 leading-relaxed">{item.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HelpPage() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme !== "light";
|
||||
const [activeId, setActiveId] = useState("overview");
|
||||
const activePage = PAGE_GUIDES.find(p => p.id === activeId);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080b14] text-white">
|
||||
{/* ── Hero Header ── */}
|
||||
<div className="relative border-b border-white/6 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-600/10 via-indigo-600/5 to-transparent pointer-events-none" />
|
||||
<div className="absolute top-0 left-1/3 w-96 h-32 bg-blue-600/8 blur-3xl pointer-events-none" />
|
||||
<div className="max-w-6xl mx-auto px-8 py-5 flex items-center justify-between relative">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-indigo-600 shadow-lg shadow-blue-500/25">
|
||||
<BookOpen className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-white">User Guide</h1>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Guide to reading Deep Packet Inspection dashboard data</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/" className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-xs font-medium text-slate-300 hover:bg-white/10 hover:text-white transition-all hover:border-white/20 group">
|
||||
<ArrowLeft className="h-3.5 w-3.5 group-hover:-translate-x-0.5 transition-transform" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto flex gap-6 px-8 py-6">
|
||||
{/* ── Sidebar ── */}
|
||||
<aside className="w-52 shrink-0">
|
||||
<div className="sticky top-6 space-y-1">
|
||||
<p className="px-3 pb-2 text-[10px] font-bold uppercase tracking-widest text-slate-500">Dashboard Pages</p>
|
||||
{PAGE_GUIDES.map(page => {
|
||||
const isActive = activeId === page.id;
|
||||
return (
|
||||
<button
|
||||
key={page.id}
|
||||
onClick={() => setActiveId(page.id)}
|
||||
className={`relative flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-xs font-medium transition-all ${
|
||||
isActive
|
||||
? "bg-gradient-to-r from-blue-600/30 to-indigo-600/20 text-white border border-blue-500/30 shadow-md shadow-blue-500/10"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-slate-200 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{isActive && <span className="absolute left-0 top-1/4 h-1/2 w-0.5 rounded-full bg-blue-500" />}
|
||||
<span className={`shrink-0 ${isActive ? "text-blue-400" : "text-slate-500"}`}>{ICON_MAP[page.id] ?? <Info className="h-4 w-4" />}</span>
|
||||
<span className="truncate">{page.title}</span>
|
||||
{isActive && <ChevronRight className="ml-auto h-3 w-3 text-blue-400 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="pt-2 border-t border-white/6 mt-2">
|
||||
<button
|
||||
onClick={() => setActiveId("conventions")}
|
||||
className={`relative flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-xs font-medium transition-all ${
|
||||
activeId === "conventions"
|
||||
? "bg-gradient-to-r from-blue-600/30 to-indigo-600/20 text-white border border-blue-500/30"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-slate-200 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{activeId === "conventions" && <span className="absolute left-0 top-1/4 h-1/2 w-0.5 rounded-full bg-blue-500" />}
|
||||
<Info className={`h-4 w-4 shrink-0 ${activeId === "conventions" ? "text-blue-400" : "text-slate-500"}`} />
|
||||
<span>Visual Conventions</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Content ── */}
|
||||
<main className="flex-1 min-w-0 space-y-4">
|
||||
{activeId === "conventions" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-gradient-to-br from-slate-900/80 to-slate-800/30 p-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-purple-500/30 to-indigo-500/20 border border-purple-500/20 text-lg">🎨</span>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">Visual Conventions</h2>
|
||||
<p className="text-xs text-slate-400">Guide to reading indicators and symbols across dashboard pages</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 overflow-hidden shadow-lg">
|
||||
<div className="grid grid-cols-[200px_1fr] bg-gradient-to-r from-purple-600/20 to-indigo-600/10 px-5 py-3 border-b border-white/8">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-purple-400">Symbol / Element</span>
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400">Meaning</span>
|
||||
</div>
|
||||
{UI_CONVENTIONS.map((c, i) => (
|
||||
<div key={i} className={`grid grid-cols-[200px_1fr] px-5 py-3.5 gap-4 border-b border-white/5 last:border-0 ${i % 2 === 0 ? "bg-white/[0.02]" : ""} hover:bg-white/[0.04] transition-colors`}>
|
||||
<span className="font-mono text-xs font-semibold text-purple-400">{c.symbol}</span>
|
||||
<span className="text-xs text-slate-300 leading-relaxed">{c.meaning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-blue-500/20 bg-gradient-to-br from-blue-600/10 to-indigo-600/5 p-5">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lightbulb className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm font-semibold text-yellow-300">Tips for Reading Data Effectively</span>
|
||||
</div>
|
||||
<ul className="space-y-2 text-xs text-slate-300">
|
||||
{[
|
||||
"Always check the Time Filter before analyzing — ensure the time range fits your needs.",
|
||||
"Use table filters to narrow down data specifically.",
|
||||
"Hover over truncated text (...) to view the full value.",
|
||||
"Compare Download vs Upload — a significantly larger upload could indicate data exfiltration.",
|
||||
"Use Geo Traffic to detect connections to countries that should not be accessed.",
|
||||
"Regularly check Threat Intelligence to identify active threats."
|
||||
].map((tip, i) => (
|
||||
<li key={i} className="flex gap-2"><span className="text-blue-400 shrink-0 mt-0.5">•</span><span className="leading-relaxed">{tip}</span></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : activePage ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/8 bg-gradient-to-br from-slate-900/80 to-slate-800/30 p-5 shadow-xl">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-600/30 to-indigo-600/20 border border-blue-500/20 text-2xl shadow-inner">
|
||||
{activePage.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<h2 className="text-xl font-bold text-white">{activePage.title}</h2>
|
||||
<code className="text-[11px] px-2 py-0.5 rounded-lg font-mono bg-white/6 text-slate-400 border border-white/8">
|
||||
{activePage.url}
|
||||
</code>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm text-slate-400 leading-relaxed">{activePage.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activePage.sections.map((section, i) => (
|
||||
<SectionBlock key={i} {...section} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-40 items-center justify-center text-slate-500 text-sm">
|
||||
Select a page from the sidebar to view its guide.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { ContextualHelpModal } from "./ContextualHelpModal";
|
||||
|
||||
interface HelpTriggerProps {
|
||||
pageId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HelpTrigger({ pageId, className = "" }: HelpTriggerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`group flex items-center gap-1.5 rounded-lg border border-white/8 bg-white/5 px-2 py-1 sm:px-2.5 sm:py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white hover:border-white/20 transition-all ${className}`}
|
||||
title="Learn how to read data on this page"
|
||||
>
|
||||
<BookOpen className="h-3.5 w-3.5 text-blue-500 group-hover:text-blue-400 transition-colors" />
|
||||
<span className="sm:hidden">Learn</span>
|
||||
<span className="hidden sm:inline">Learn This Page</span>
|
||||
</button>
|
||||
|
||||
<ContextualHelpModal
|
||||
pageId={pageId}
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
user: any;
|
||||
setUser: (user: any) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AccountNameTab({ user, setUser, onClose }: Props) {
|
||||
const [newAccountName, setNewAccountName] = useState("");
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-account-name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newAccountName }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Account name changed successfully!");
|
||||
setCurrentPassword("");
|
||||
if (user) {
|
||||
setUser({ ...user, account_name: data.newAccountName });
|
||||
}
|
||||
} else {
|
||||
setErrorMsg(data.error || "Failed to change account name.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "A system error occurred.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">New Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newAccountName}
|
||||
onChange={(e) => setNewAccountName(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none"
|
||||
placeholder="e.g., Super Admin Budi"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Current Password (Authorization)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter password to continue"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newAccountName || !currentPassword || isSubmitting}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmitting ? "Saving..." : "Save Account Name"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
|
||||
import AccountNameTab from "./AccountNameTab";
|
||||
import ProfilePictureTab from "./ProfilePictureTab";
|
||||
import UsernameTab from "./UsernameTab";
|
||||
import PasswordTab from "./PasswordTab";
|
||||
import ViewAsHistoryTab from "./ViewAsHistoryTab";
|
||||
|
||||
type Tab = 'account_name' | 'username' | 'password' | 'profile_picture' | 'view_as_history';
|
||||
|
||||
export function AccountSettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
setUser
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: any;
|
||||
setUser: (user: any) => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('account_name');
|
||||
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'TENANT_ADMIN';
|
||||
const canViewHistory = isAdmin || user?.role === 'SOC_ANALYST';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Account Settings"
|
||||
className="sm:max-w-3xl sm:w-full h-[520px]"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-6 h-[430px]">
|
||||
{/* Left Sidebar Layout (Fixed Width & Height) */}
|
||||
<div className="w-full md:w-[180px] flex flex-row md:flex-col gap-1 border-b md:border-b-0 md:border-r border-border/50 pb-2.5 md:pb-0 md:pr-4 shrink-0 overflow-x-auto md:overflow-x-visible custom-scrollbar h-auto md:h-full">
|
||||
<button
|
||||
onClick={() => setActiveTab('account_name')}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left",
|
||||
activeTab === 'account_name'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
Change Name
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('profile_picture')}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left",
|
||||
activeTab === 'profile_picture'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
Profile Picture
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('username')}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left",
|
||||
activeTab === 'username'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
Username
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left",
|
||||
activeTab === 'password'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
Password
|
||||
</button>
|
||||
|
||||
{canViewHistory && (
|
||||
<button
|
||||
onClick={() => setActiveTab('view_as_history')}
|
||||
className={cn(
|
||||
"flex items-center w-full px-3.5 py-2.5 text-[11px] uppercase tracking-wider font-bold rounded-xl transition-all text-left whitespace-nowrap",
|
||||
activeTab === 'view_as_history'
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-white/[0.02]"
|
||||
)}
|
||||
>
|
||||
View As History
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Content Pane (Fixed Height & Scrollable for strict Rule 9 size consistency) */}
|
||||
<div className="flex-1 min-w-0 h-[320px] md:h-full overflow-y-auto pr-2 custom-scrollbar">
|
||||
{activeTab === 'account_name' && (
|
||||
<AccountNameTab user={user} setUser={setUser} onClose={onClose} />
|
||||
)}
|
||||
|
||||
{activeTab === 'profile_picture' && (
|
||||
<ProfilePictureTab user={user} setUser={setUser} onClose={onClose} />
|
||||
)}
|
||||
|
||||
{activeTab === 'username' && (
|
||||
<UsernameTab user={user} setUser={setUser} onClose={onClose} />
|
||||
)}
|
||||
|
||||
{activeTab === 'password' && (
|
||||
<PasswordTab onClose={onClose} />
|
||||
)}
|
||||
|
||||
{activeTab === 'view_as_history' && canViewHistory && (
|
||||
<ViewAsHistoryTab onClose={onClose} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState, useEffect } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { getViewAsStatusSync, validateViewAsSession } from "@/lib/admin-api";
|
||||
import { HelpCenterFAB } from "@/components/ui/HelpCenterFAB";
|
||||
import { useInactivityTimeout } from "@/hooks/useInactivityTimeout";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getSiteBranding } from "@/lib/branding";
|
||||
import { MobileTopBar } from "./MobileTopBar";
|
||||
import { MobileBottomBar } from "./MobileBottomBar";
|
||||
import { ViewAsBanner } from "./ViewAsBanner";
|
||||
import { SessionTimeoutModal } from "./SessionTimeoutModal";
|
||||
|
||||
export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [viewAs, setViewAs] = useState<any>({ active: false });
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const branding = getSiteBranding();
|
||||
const getPageTitlePrefix = (path: string): string => {
|
||||
switch (path) {
|
||||
case "/": return "Overview Dashboard";
|
||||
case "/agents": return "Agents";
|
||||
case "/apps": return "Apps";
|
||||
case "/devices": return "Devices";
|
||||
case "/dns": return "DNS";
|
||||
case "/dpi-analytics": return "DPI MetaData";
|
||||
case "/events": return "Recent Events";
|
||||
case "/flows": return "Flows";
|
||||
case "/geography": return "Geo Traffic";
|
||||
case "/intelligence": return "Threat Intelligence";
|
||||
case "/lookup": return "App Lookup";
|
||||
case "/network-infrastructure": return "Network Topology";
|
||||
case "/network-intelligence": return "Traffic Categories";
|
||||
case "/threats": return "Detected Threats";
|
||||
case "/help": return "User Guide";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
const updateTitleAndFavicon = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const prefix = getPageTitlePrefix(pathname);
|
||||
document.title = prefix ? `${prefix} | ${branding.name} - Deep Package Inspection` : `${branding.name} - Deep Package Inspection`;
|
||||
const logoUrl = branding.logo;
|
||||
const links = document.querySelectorAll("link[rel*='icon']");
|
||||
if (links.length > 0) {
|
||||
links.forEach(l => (l as HTMLLinkElement).href = logoUrl);
|
||||
} else {
|
||||
const l = document.createElement('link');
|
||||
l.rel = 'icon';
|
||||
l.href = logoUrl;
|
||||
document.head.appendChild(l);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateTitleAndFavicon();
|
||||
const timer = setTimeout(updateTitleAndFavicon, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [pathname]);
|
||||
|
||||
const {
|
||||
showWarning: showTimeoutWarning,
|
||||
timeLeft,
|
||||
isRenewing,
|
||||
handleRenewSession,
|
||||
handleLogout
|
||||
} = useInactivityTimeout({
|
||||
timeoutSeconds: 60 * 60,
|
||||
warningSeconds: 2 * 60
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { credentials: 'include' })
|
||||
.then(res => {
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/login";
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.user) {
|
||||
setUser(data.user);
|
||||
const originalRole = data.user._originalRole || data.user.role;
|
||||
setViewAs(validateViewAsSession(originalRole));
|
||||
} else {
|
||||
setViewAs(getViewAsStatusSync());
|
||||
}
|
||||
})
|
||||
.catch(() => setViewAs(getViewAsStatusSync()));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-transparent transition-colors duration-500 relative">
|
||||
{/* Desktop Sidebar (hidden on mobile/tablet) */}
|
||||
<div className="hidden lg:flex h-full shrink-0">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Mobile / Tablet Drawer Sidebar (hidden on desktop) */}
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex lg:hidden transition-all duration-500 ease-in-out ${
|
||||
isMobileMenuOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
<div
|
||||
className={`relative z-10 flex w-72 max-w-[80vw] flex-col bg-card shadow-2xl h-full transform transition-transform duration-500 ease-in-out ${
|
||||
isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<Sidebar isMobile onClose={() => setIsMobileMenuOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden w-full min-w-0 relative transition-all duration-500 ease-in-out">
|
||||
<MobileTopBar onOpenMenu={() => setIsMobileMenuOpen(true)} />
|
||||
<ViewAsBanner viewAs={viewAs} />
|
||||
|
||||
<main suppressHydrationWarning className="flex-1 overflow-y-auto overflow-x-hidden bg-transparent p-2.5 sm:p-5 lg:p-8 pb-24 lg:pb-8 transition-all duration-500 ease-in-out w-full relative min-w-0">
|
||||
<div suppressHydrationWarning className="w-full min-w-0 space-y-4 sm:space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Navigation Bar (< 1024px) */}
|
||||
<MobileBottomBar onOpenMenu={() => setIsMobileMenuOpen(true)} />
|
||||
|
||||
<SessionTimeoutModal
|
||||
showWarning={showTimeoutWarning}
|
||||
timeLeft={timeLeft}
|
||||
isRenewing={isRenewing}
|
||||
onRenew={handleRenewSession}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<HelpCenterFAB />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import ViewAsAgentBanner from "@/components/admin/ViewAsAgentBanner";
|
||||
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
const [userRole, setUserRole] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.user?.role) setUserRole(d.user.role);
|
||||
})
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
|
||||
const getPageTitle = () => {
|
||||
if (pathname === "/apps") return "Applications";
|
||||
if (pathname === "/devices") return "Devices Inventory";
|
||||
if (pathname === "/threats") return "Cyber Threats";
|
||||
if (pathname === "/flows") return "Network Flows";
|
||||
if (pathname === "/dns") return "DNS Queries";
|
||||
if (pathname === "/events") return "Security Events";
|
||||
return "Network Overview";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full z-50 sticky top-0 bg-background/70 backdrop-blur-xl border-b border-border/40">
|
||||
<ViewAsAgentBanner />
|
||||
|
||||
<div className="flex items-center justify-between px-6 py-4 h-16">
|
||||
{/* Left Side: Page Title Only (Minimalist) */}
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<h1 className="text-xl font-bold text-foreground flex items-center tracking-tight">
|
||||
{getPageTitle()}
|
||||
{pathname === '/threats' && (
|
||||
<span className="ml-3 flex h-2 w-2 relative">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Right Side */}
|
||||
<div className="flex items-center">
|
||||
{/* Site switcher moved to Sidebar */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LayoutDashboard, Laptop, Activity, ShieldAlert, Menu } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MobileBottomBarProps {
|
||||
onOpenMenu: () => void;
|
||||
}
|
||||
|
||||
export function MobileBottomBar({ onOpenMenu }: MobileBottomBarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const navItems = [
|
||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||
{ name: "Devices", href: "/devices", icon: Laptop },
|
||||
{ name: "Flows", href: "/flows", icon: Activity },
|
||||
{ name: "Threats", href: "/threats", icon: ShieldAlert },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-40 lg:hidden bg-card/95 border-t border-border/80 backdrop-blur-2xl shadow-2xl px-1 py-1.5 flex items-center justify-between w-full">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-center justify-center py-1 px-1 rounded-xl transition-all duration-200 relative min-w-0 text-center",
|
||||
isActive
|
||||
? "text-primary font-bold"
|
||||
: "text-muted-foreground hover:text-foreground active:scale-95"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<span className="absolute top-0 w-6 h-0.5 bg-primary rounded-full shadow-[0_0_8px_rgba(59,130,246,0.8)]" />
|
||||
)}
|
||||
<Icon className={cn("w-4 h-4 sm:w-5 sm:h-5 mb-0.5 transition-transform duration-200 shrink-0", isActive && "scale-110 text-primary")} />
|
||||
<span className="text-[10px] sm:text-xs font-medium tracking-tight truncate w-full">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Menu Drawer Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMenu}
|
||||
className="flex-1 flex flex-col items-center justify-center py-1 px-1 rounded-xl min-w-0 text-center text-muted-foreground hover:text-foreground active:scale-95 transition-all duration-200"
|
||||
>
|
||||
<Menu className="w-4 h-4 sm:w-5 sm:h-5 mb-0.5 text-slate-300 shrink-0" />
|
||||
<span className="text-[10px] sm:text-xs font-medium tracking-tight text-slate-300 truncate w-full">Menu</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getSiteBranding } from "@/lib/branding";
|
||||
import SidebarNotifications from "./SidebarNotifications";
|
||||
|
||||
interface MobileTopBarProps {
|
||||
onOpenMenu: () => void;
|
||||
selectedSite?: string;
|
||||
}
|
||||
|
||||
export function MobileTopBar({ onOpenMenu, selectedSite }: MobileTopBarProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [site, setSite] = useState<string | undefined>(selectedSite);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (!selectedSite && typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem("backone_site_uuid");
|
||||
if (stored) setSite(stored);
|
||||
}
|
||||
}, [selectedSite]);
|
||||
|
||||
const branding = getSiteBranding(site);
|
||||
|
||||
const getPageTitle = (path: string): string => {
|
||||
switch (path) {
|
||||
case "/": return "Overview";
|
||||
case "/devices": return "Devices";
|
||||
case "/flows": return "Flows";
|
||||
case "/threats": return "Threats";
|
||||
case "/apps": return "Apps";
|
||||
case "/agents": return "Agents";
|
||||
case "/dns": return "DNS Traffic";
|
||||
case "/dpi-analytics": return "DPI Analytics";
|
||||
case "/events": return "Events";
|
||||
case "/geography": return "Geo Traffic";
|
||||
case "/intelligence": return "Intelligence";
|
||||
case "/lookup": return "App Catalog";
|
||||
case "/network-infrastructure": return "Topology";
|
||||
case "/network-intelligence": return "Categories";
|
||||
case "/user-accounts": return "User Accounts";
|
||||
case "/device-labeling": return "Labeling";
|
||||
default: return "Dashboard";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="lg:hidden flex items-center justify-between px-4 py-3 bg-card/90 border-b border-border/80 backdrop-blur-xl sticky top-0 z-30 w-full shrink-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex items-center gap-2 shrink-0" suppressHydrationWarning>
|
||||
<img
|
||||
src={mounted ? branding.logo : "/backone-logo.png"}
|
||||
alt={mounted ? branding.name : "Logo"}
|
||||
className="w-6 h-6 object-contain"
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border/60 shrink-0" />
|
||||
<h1 className="font-bold text-sm text-white tracking-tight truncate">
|
||||
{getPageTitle(pathname)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center gap-1 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[10px] font-bold px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="truncate max-w-[70px]">{mounted ? branding.name : "DPI"}</span>
|
||||
</div>
|
||||
<SidebarNotifications placement="topbar" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PasswordTab({ onClose }: Props) {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [showCurrent, setShowCurrent] = useState(false);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
|
||||
const isMinLength = newPassword.length >= 6;
|
||||
const hasUppercase = /[A-Z]/.test(newPassword);
|
||||
const hasLowercase = /[a-z]/.test(newPassword);
|
||||
const hasNumber = /[0-9]/.test(newPassword);
|
||||
const passwordsMatch = newPassword === confirmPassword && confirmPassword.length > 0;
|
||||
const isPasswordValid = isMinLength && hasUppercase && hasLowercase && hasNumber && passwordsMatch;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isPasswordValid) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Password changed successfully!");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
setErrorMsg(data.error || "Failed to change password.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "A system error occurred.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Current Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrent ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter current password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrent(!showCurrent)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showCurrent ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNew ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Enter new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showNew ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-slate-300">Confirm New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirm ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full rounded-lg border border-border bg-background/50 backdrop-blur-sm px-4 py-2.5 text-sm text-white placeholder-slate-500 focus:border-primary focus:ring-1 focus:ring-primary/50 transition-all focus:outline-none pr-10"
|
||||
placeholder="Re-enter new password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirm(!showConfirm)}
|
||||
className="absolute right-3 top-2.5 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showConfirm ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-gradient-to-br from-secondary/40 to-background p-4 border border-border/50 shadow-inner space-y-3">
|
||||
<span className="text-xs font-semibold text-slate-200">Password Strength Requirements:</span>
|
||||
<ul className="text-xs space-y-2">
|
||||
<li className={cn("flex items-center transition-colors duration-200", isMinLength ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{isMinLength ? "✓" : "○"}</span> At least 6 characters
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasUppercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasUppercase ? "✓" : "○"}</span> Contains uppercase letter (A-Z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasLowercase ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasLowercase ? "✓" : "○"}</span> Contains lowercase letter (a-z)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", hasNumber ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{hasNumber ? "✓" : "○"}</span> Contains number (0-9)
|
||||
</li>
|
||||
<li className={cn("flex items-center transition-colors duration-200", passwordsMatch ? "text-green-400 font-medium" : "text-slate-400")}>
|
||||
<span className="mr-2.5 text-sm">{passwordsMatch ? "✓" : "○"}</span> Passwords match
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isPasswordValid || isSubmitting}
|
||||
className="rounded-lg px-6 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : null}
|
||||
{isSubmitting ? "Saving..." : "Save Password"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Upload, ImageIcon, Trash2 } from "lucide-react";
|
||||
import { SafeImage } from "../ui/SafeImage";
|
||||
|
||||
interface Props {
|
||||
user: any;
|
||||
setUser: (user: any) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ProfilePictureTab({ user, setUser, onClose }: Props) {
|
||||
const [profilePicFile, setProfilePicFile] = useState<File | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleUpload = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!profilePicFile) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
// Konversi file ke base64 data URL — menghindari masalah multipart/form-data
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(profilePicFile);
|
||||
});
|
||||
|
||||
const res = await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_picture_base64: base64 }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Profile picture updated successfully!");
|
||||
setProfilePicFile(null);
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: data.profile_picture });
|
||||
}
|
||||
} else {
|
||||
setErrorMsg(data.error || "Failed to upload profile picture.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "A system error occurred.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async () => {
|
||||
setIsUploading(true);
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth/remove-profile-picture', {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSuccessMsg("Profile picture deleted successfully!");
|
||||
if (user) {
|
||||
setUser({ ...user, profile_picture: undefined });
|
||||
}
|
||||
} else {
|
||||
setErrorMsg("Failed to delete profile picture.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setErrorMsg(err.message || "A system error occurred.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-xs">
|
||||
{errorMsg && (
|
||||
<div className="rounded-lg bg-red-500/10 p-3 text-red-400 border border-red-500/20">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-lg bg-green-500/10 p-3 text-green-400 border border-green-500/20">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center py-4">
|
||||
<div className="h-24 w-24 rounded-full bg-gradient-to-tr from-primary to-blue-400 p-0.5 shadow-lg shadow-primary/20 mb-4 overflow-hidden relative group">
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-background overflow-hidden relative">
|
||||
{user?.profile_picture ? (
|
||||
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-3xl font-bold text-white uppercase">
|
||||
{user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : 'A')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 w-full">
|
||||
<form onSubmit={handleUpload} className="flex-1 flex flex-col space-y-3">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => setProfilePicFile(e.target.files ? e.target.files[0] : null)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<div className="flex items-center justify-center w-full px-4 py-2 border border-dashed border-border rounded-lg bg-secondary/30 hover:bg-secondary/50 transition-colors">
|
||||
<Upload className="w-4 h-4 mr-2 text-slate-400" />
|
||||
<span className="text-sm text-slate-300 truncate">
|
||||
{profilePicFile ? profilePicFile.name : "Choose Image File"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!profilePicFile || isUploading}
|
||||
className="w-full rounded-lg px-4 py-2 text-sm font-medium text-[#fff] shadow-lg bg-gradient-to-r from-blue-600 to-blue-800 hover:from-blue-500 hover:to-blue-700 disabled:from-slate-700 disabled:to-slate-800 disabled:text-slate-500 disabled:shadow-none transition-all duration-300 flex items-center justify-center"
|
||||
>
|
||||
{isUploading ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isUploading ? "Uploading..." : "Upload Photo"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{user?.profile_picture && (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={isUploading}
|
||||
className="flex flex-col items-center justify-center px-4 py-2 border border-red-500/30 rounded-lg text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors shrink-0 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-5 h-5 mb-1" />
|
||||
<span className="text-[10px] font-medium">Delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-border/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:bg-secondary hover:text-white transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { ShieldAlert, Clock, Loader2 } from "lucide-react";
|
||||
|
||||
interface SessionTimeoutModalProps {
|
||||
showWarning: boolean;
|
||||
timeLeft: number;
|
||||
isRenewing: boolean;
|
||||
onRenew: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export function SessionTimeoutModal({
|
||||
showWarning,
|
||||
timeLeft,
|
||||
isRenewing,
|
||||
onRenew,
|
||||
onLogout
|
||||
}: SessionTimeoutModalProps) {
|
||||
const formatCountdown = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s.toString().padStart(2, '0')}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={showWarning} onClose={() => {}} title="Session Security Alert">
|
||||
<div className="space-y-5 text-xs text-slate-300">
|
||||
<div className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
||||
<ShieldAlert className="w-5 h-5 text-amber-400 shrink-0" />
|
||||
<p className="font-medium text-slate-200">
|
||||
For your security, your active dashboard session will automatically expire due to inactivity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-6 bg-slate-900/40 border border-white/5 rounded-2xl relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-blue-500/5 blur-3xl rounded-full" />
|
||||
<Clock className="w-8 h-8 text-blue-400 mb-2 relative animate-pulse" />
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest relative">Remaining Time</span>
|
||||
<span className="text-3xl font-black text-white font-mono tracking-tight mt-1 relative">
|
||||
{formatCountdown(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="px-4 py-2 text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-xl transition-all"
|
||||
>
|
||||
Logout Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRenew}
|
||||
disabled={isRenewing}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/90 active:scale-[0.98] text-primary-foreground px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isRenewing ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Keep Me Logged In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// src/components/layout/Sidebar.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { X } from "lucide-react";
|
||||
import { AccountSettingsModal } from "./AccountSettingsModal";
|
||||
import { getViewAsHeaders, startViewAs, clearViewAsState } from "@/lib/admin-api";
|
||||
import { NAVIGATION_GROUPS } from "./SidebarData";
|
||||
import SidebarProfile from "./SidebarProfile";
|
||||
import SidebarNotifications from "./SidebarNotifications";
|
||||
import SidebarGroupSelector from "./SidebarGroupSelector";
|
||||
import SidebarTimeSelector from "./SidebarTimeSelector";
|
||||
import { getSiteBranding } from "@/lib/branding";
|
||||
|
||||
interface SidebarProps {
|
||||
isMobile?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ isMobile = false, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<{ username?: string; account_name?: string; profile_picture?: string; role?: string; agent_uuid?: string; agent_uuids?: string[]; company_name?: string; _isViewAsMode?: boolean } | null>(null);
|
||||
const [isAccountOpen, setIsAccountOpen] = useState(false);
|
||||
const [intervalText, setIntervalText] = useState<string>("");
|
||||
const [selectedSite, setSelectedSite] = useState<string>("all");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||
.then(res => {
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/login";
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.user) {
|
||||
setUser(data.user);
|
||||
|
||||
// Role-aware site UUID logic:
|
||||
// - Global roles (SUPER_ADMIN, EXECUTIVE, SOC_ANALYST, ENGINEER):
|
||||
// Prioritaskan pilihan yang TERSIMPAN di localStorage.
|
||||
// Hanya set dari DB jika belum ada pilihan tersimpan.
|
||||
// Ini agar site switcher tidak reset ke SIAB setiap page reload.
|
||||
// - Restricted roles (TENANT_ADMIN, AGENT_VIEWER, COMPANY_*):
|
||||
// Selalu pakai site_uuid dari DB (mereka tidak bisa ganti site).
|
||||
const globalRoles = ['SUPER_ADMIN', 'EXECUTIVE', 'SOC_ANALYST', 'ENGINEER'];
|
||||
const isGlobalRole = globalRoles.includes(data.user.role || '');
|
||||
const storedSiteUuid = localStorage.getItem('backone_site_uuid');
|
||||
|
||||
if (isGlobalRole) {
|
||||
// Pakai localStorage jika sudah ada, fallback ke DB, fallback ke SIAB default
|
||||
const siteToUse = storedSiteUuid || data.user.site_uuid || 'all';
|
||||
localStorage.setItem('backone_site_uuid', siteToUse);
|
||||
setSelectedSite(siteToUse);
|
||||
} else if (data.user.site_uuid) {
|
||||
// Restricted roles: selalu pakai site_uuid dari DB (tidak bisa switch)
|
||||
localStorage.setItem('backone_site_uuid', data.user.site_uuid);
|
||||
setSelectedSite(data.user.site_uuid);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
fetch('/api/dashboard/data-interval')
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (res.ok && res.data) {
|
||||
const { start, end } = res.data;
|
||||
if (start && end) {
|
||||
const sDate = new Date(start);
|
||||
const eDate = new Date(end);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: 'numeric', month: 'short', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', hour12: false
|
||||
};
|
||||
setIntervalText(`${sDate.toLocaleString('id-ID', options)} - ${eDate.toLocaleString('id-ID', options)}`);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem('backone_site_uuid');
|
||||
if (stored) {
|
||||
setSelectedSite(stored);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const branding = getSiteBranding(selectedSite);
|
||||
const isNexus = branding.name === "Nexus";
|
||||
|
||||
|
||||
|
||||
const handleSiteSelect = async (val: string) => {
|
||||
if (val === 'all') {
|
||||
clearViewAsState();
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(val);
|
||||
if (parsed.uuid && parsed.label) {
|
||||
await startViewAs(parsed.uuid, parsed.label);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse agent selection", e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full max-h-screen flex-col border-r border-white/5 bg-black/20 backdrop-blur-3xl transition-all w-64 shrink-0 relative z-40 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.5)]"
|
||||
style={{ fontFamily: "'Times New Roman', Times, serif" }}
|
||||
>
|
||||
{/* Brand & Group Info */}
|
||||
<div className="flex flex-col p-2 shrink-0 gap-4 border-b border-white/5 relative">
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col items-center justify-center w-full pt-6 pb-3 px-3 text-center mx-auto" suppressHydrationWarning>
|
||||
<img
|
||||
src={mounted ? branding.logo : "/backone-logo.png"}
|
||||
alt="Logo"
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"w-12 h-12 object-contain shrink-0 mb-3",
|
||||
(mounted && isNexus)
|
||||
? "drop-shadow-[0_0_12px_rgba(6,182,212,0.45)]"
|
||||
: "drop-shadow-[0_0_12px_rgba(168,85,247,0.45)]"
|
||||
)}
|
||||
/>
|
||||
<span className="text-[9.5px] uppercase tracking-[0.18em] text-slate-300 font-mono font-bold block">
|
||||
Deep Package Inspection
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-2.5 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[10px] font-bold px-3 py-1 rounded-full w-fit">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
DPI ENGINE: ACTIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SidebarGroupSelector
|
||||
user={user}
|
||||
selectedSite={selectedSite}
|
||||
handleSiteSelect={handleSiteSelect}
|
||||
/>
|
||||
|
||||
<SidebarTimeSelector />
|
||||
</div>
|
||||
|
||||
{/* Navigation (Flexible Scrollable Container for Rule 9) */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 custom-scrollbar h-full">
|
||||
{intervalText && (
|
||||
<div className="mb-4 rounded-lg bg-blue-500/5 border border-blue-500/10 p-2 text-center">
|
||||
<span className="text-[9px] uppercase tracking-wider text-blue-400 font-bold block mb-0.5">Telemetry Window</span>
|
||||
<span className="text-[10px] text-slate-400 block font-mono">{intervalText}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="flex flex-col gap-5">
|
||||
{NAVIGATION_GROUPS.map((group) => {
|
||||
const allowedItems = group.items.filter((item) => {
|
||||
if (item.href === "/agents") {
|
||||
return user?.role === "SUPER_ADMIN" || user?.role === "EXECUTIVE" || user?.role === "TENANT_ADMIN" || user?.role === "COMPANY_ADMIN" || user?.role === "COMPANY_OPERATOR";
|
||||
}
|
||||
if (item.href === "/device-labeling") {
|
||||
return user?.role === "SUPER_ADMIN" || user?.role === "EXECUTIVE" || user?.role === "TENANT_ADMIN" || user?.role === "COMPANY_ADMIN" || user?.role === "COMPANY_OPERATOR";
|
||||
}
|
||||
if (item.href === "/user-accounts") {
|
||||
return user?.role === "SUPER_ADMIN" || user?.role === "EXECUTIVE" || user?.role === "COMPANY_ADMIN";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (allowedItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={group.title} className="flex flex-col gap-1">
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-muted-foreground px-2">
|
||||
{group.title}
|
||||
</span>
|
||||
{allowedItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => onClose?.()}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-xl px-3 py-2 text-xs font-medium tracking-wide transition-all duration-300",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary font-bold shadow-md shadow-primary/5"
|
||||
: "text-slate-400 hover:bg-white/[0.02] hover:text-slate-200"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-500",
|
||||
isActive ? "text-primary" : "text-muted-foreground group-hover:text-foreground group-hover:scale-110"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="truncate">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Footer (Account & Notifications) */}
|
||||
<div className="mt-auto p-4 border-t border-white/5 bg-white/5 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarProfile user={user} onOpenSettings={() => { setIsAccountOpen(true); onClose?.(); }} />
|
||||
<SidebarNotifications />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountSettingsModal isOpen={isAccountOpen} onClose={() => setIsAccountOpen(false)} user={user} setUser={setUser} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
LayoutDashboard, Server, Activity, Globe, Radio,
|
||||
ShieldAlert, Shield, AppWindow, Search, Router,
|
||||
PieChart, Database, Network, Zap, Tag, Users
|
||||
} from "lucide-react";
|
||||
|
||||
export const NAVIGATION_GROUPS = [
|
||||
{
|
||||
title: "OVERVIEW",
|
||||
items: [
|
||||
{ name: "Overview Dashboard", href: "/", icon: LayoutDashboard },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "INFRASTRUCTURE",
|
||||
items: [
|
||||
{ name: "Network Topology", href: "/network-infrastructure", icon: Network },
|
||||
{ name: "Agents", href: "/agents", icon: Server },
|
||||
{ name: "Devices", href: "/devices", icon: Router },
|
||||
{ name: "Device Labeling", href: "/device-labeling", icon: Tag },
|
||||
{ name: "User Account", href: "/user-accounts", icon: Users },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "TRAFFIC & ANALYTICS",
|
||||
items: [
|
||||
{ name: "Flows", href: "/flows", icon: Activity },
|
||||
{ name: "Apps", href: "/apps", icon: AppWindow },
|
||||
{ name: "Traffic Categories", href: "/network-intelligence", icon: PieChart },
|
||||
{ name: "DNS", href: "/dns", icon: Radio },
|
||||
{ name: "Geo Traffic", href: "/geography", icon: Globe },
|
||||
{ name: "DPI MetaData", href: "/dpi-analytics", icon: Database },
|
||||
{ name: "App Lookup", href: "/lookup", icon: Search },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "SECURITY & ALERTS",
|
||||
items: [
|
||||
{ name: "Threat Intelligence", href: "/intelligence", icon: Shield },
|
||||
{ name: "Detected Threats", href: "/threats", icon: ShieldAlert },
|
||||
{ name: "Network Events", href: "/events", icon: Zap },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const TIME_OPTIONS = [
|
||||
{ value: "5m", label: "Last 5 Minutes" },
|
||||
{ value: "30m", label: "Last 30 Minutes" },
|
||||
{ value: "1h", label: "Last 1 Hour" },
|
||||
{ value: "1d", label: "Last 24 Hours" },
|
||||
{ value: "7d", label: "Last 7 Days" },
|
||||
{ value: "30d", label: "Last 30 Days" },
|
||||
] as const;
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Server, Loader2 } from "lucide-react";
|
||||
|
||||
interface SidebarGroupSelectorProps {
|
||||
user: { role?: string } | null;
|
||||
selectedSite: string;
|
||||
handleSiteSelect: (val: string) => void;
|
||||
}
|
||||
|
||||
export default function SidebarGroupSelector({
|
||||
user,
|
||||
selectedSite,
|
||||
handleSiteSelect, // This will now act as handleAgentSelect internally
|
||||
}: SidebarGroupSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [agents, setAgents] = useState<{uuid: string; label: string}[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentViewAsLabel, setCurrentViewAsLabel] = useState<string | null>(null);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Fetch agents and view as status
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
// Check if view-as is active
|
||||
try {
|
||||
const stored = localStorage.getItem('backone_view_as');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && parsed.active && parsed.agent_label) {
|
||||
setCurrentViewAsLabel(parsed.agent_label);
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
const fetchAgents = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/agents/list');
|
||||
const json = await res.json();
|
||||
if (json && json.ok && json.data) {
|
||||
setAgents(json.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch agents:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAgents();
|
||||
}, [user]);
|
||||
|
||||
// Allow all admin-level roles to see the Group Selector
|
||||
if (!user || !['SUPER_ADMIN', 'TENANT_ADMIN', 'COMPANY_ADMIN', 'EXECUTIVE', 'SOC_ANALYST', 'ENGINEER'].includes(user.role || '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getGroupLabel = () => {
|
||||
if (currentViewAsLabel) return currentViewAsLabel;
|
||||
return 'All Agents';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full px-2" ref={ref} style={{ fontFamily: "'Times New Roman', Times, serif" }}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 bg-white/[0.03] hover:bg-white/[0.08] border border-white/[0.08] hover:border-white/[0.15] px-3.5 py-2.5 rounded-xl transition-all duration-200 text-left cursor-pointer backdrop-blur-md",
|
||||
isOpen && "border-blue-500/30 ring-1 ring-blue-500/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-4 w-4 text-slate-400 shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 leading-none mb-1">
|
||||
ACTIVE AGENT
|
||||
</span>
|
||||
<span className="text-xs font-medium text-slate-300 leading-none truncate max-w-[140px]">
|
||||
{getGroupLabel()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-slate-600 transition-transform duration-300 shrink-0", isOpen && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-2 right-2 mt-1.5 rounded-xl border border-white/10 bg-slate-950/95 backdrop-blur-xl p-1.5 shadow-2xl z-50 origin-top transition-all duration-300 ease-out max-h-[300px] overflow-y-auto custom-scrollbar",
|
||||
isOpen
|
||||
? "opacity-100 scale-100 pointer-events-auto translate-y-0"
|
||||
: "opacity-0 scale-95 pointer-events-none -translate-y-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleSiteSelect('all');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors font-medium",
|
||||
!currentViewAsLabel ? "bg-blue-500/20 text-blue-400" : "hover:bg-white/5 text-slate-300 hover:text-white"
|
||||
)}
|
||||
>
|
||||
All Agents
|
||||
</button>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center p-2">
|
||||
<Loader2 className="w-4 h-4 text-slate-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && agents.map((agent) => (
|
||||
<button
|
||||
key={agent.uuid}
|
||||
onClick={() => {
|
||||
// Pass a JSON string to handleSiteSelect to pass both uuid and label
|
||||
handleSiteSelect(JSON.stringify({ uuid: agent.uuid, label: agent.label }));
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors truncate font-medium",
|
||||
currentViewAsLabel === agent.label ? "bg-blue-500/20 text-blue-400" : "hover:bg-white/5 text-slate-300 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{agent.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// src/components/layout/SidebarNotifications.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Bell, AlertTriangle, Info, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNotifications, AppNotification } from "./useNotifications";
|
||||
|
||||
interface SidebarNotificationsProps {
|
||||
placement?: "sidebar" | "topbar";
|
||||
}
|
||||
|
||||
export default function SidebarNotifications({ placement = "sidebar" }: SidebarNotificationsProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const { notifications, unreadCount, readIds, markAllAsRead, markAsRead } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleNotificationClick = (n: AppNotification) => {
|
||||
markAsRead(n.id);
|
||||
setIsOpen(false);
|
||||
router.push(n.type === 'threat' ? `/threats?highlight=${n.dbId}` : `/events?highlight=${n.dbId}`);
|
||||
};
|
||||
|
||||
const getSeverityStyles = (severity: string) => {
|
||||
const s = severity.toLowerCase();
|
||||
if (s.includes('critical') || s.includes('high')) {
|
||||
return { bg: 'bg-red-500/10 hover:bg-red-500/15', border: 'border-red-500/20', text: 'text-red-400', icon: <AlertTriangle className="w-4 h-4 text-red-500 shrink-0" /> };
|
||||
}
|
||||
if (s.includes('med') || s.includes('warning')) {
|
||||
return { bg: 'bg-yellow-500/10 hover:bg-yellow-500/15', border: 'border-yellow-500/20', text: 'text-yellow-400', icon: <AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0" /> };
|
||||
}
|
||||
return { bg: 'bg-blue-500/10 hover:bg-blue-500/15', border: 'border-blue-500/20', text: 'text-blue-400', icon: <Info className="w-4 h-4 text-blue-500 shrink-0" /> };
|
||||
};
|
||||
|
||||
const getPopoverPositionClass = (): string => {
|
||||
if (placement === "topbar") {
|
||||
return "fixed top-14 right-3 sm:right-6 w-[calc(100vw-24px)] max-w-sm z-[99999]";
|
||||
}
|
||||
return "fixed bottom-16 left-4 sm:left-72 w-[calc(100vw-32px)] sm:w-96 max-w-sm z-[99999]";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={
|
||||
placement === "topbar"
|
||||
? "relative p-2 rounded-xl text-slate-300 hover:text-white hover:bg-secondary/80 transition-colors flex items-center justify-center"
|
||||
: "relative flex h-10 w-10 items-center justify-center rounded-xl text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className={placement === "topbar" ? "w-4 h-4" : "h-5 w-5"} />
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className={
|
||||
placement === "topbar"
|
||||
? "absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] px-1 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white shadow-md ring-2 ring-card"
|
||||
: "absolute right-1.5 top-1.5 flex h-4 min-w-[16px] px-1 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white shadow-md ring-2 ring-card"
|
||||
}
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{mounted && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`${getPopoverPositionClass()} rounded-2xl border border-border bg-card/95 backdrop-blur-xl p-3.5 shadow-2xl transition-all duration-500 ease-in-out transform ${
|
||||
placement === "topbar" ? "origin-top-right" : "origin-bottom-left"
|
||||
} ${
|
||||
isOpen
|
||||
? "opacity-100 scale-100 translate-y-0 pointer-events-auto"
|
||||
: "opacity-0 scale-95 -translate-y-2 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2.5 mb-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-bold text-foreground">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-red-500/20 border border-red-500/30 text-red-400 text-[10px] font-bold px-1.5 py-0.5 rounded-full">
|
||||
{unreadCount} new
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-primary hover:text-primary/80 font-medium transition-colors"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-secondary/80 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">No new notifications</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const isRead = readIds.includes(n.id);
|
||||
const styles = getSeverityStyles(n.severity);
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`flex gap-2.5 p-2.5 rounded-xl border transition-all cursor-pointer ${styles.bg} ${styles.border} ${!isRead ? 'ring-1 ring-primary/30 opacity-100' : 'opacity-60'}`}
|
||||
>
|
||||
{styles.icon}
|
||||
<div className="flex-1 space-y-0.5 min-w-0">
|
||||
<div className="flex justify-between items-start gap-1">
|
||||
<span className={`text-xs font-bold ${styles.text} truncate`}>{n.title}</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-300 line-clamp-2 leading-tight">{n.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// src/components/layout/SidebarProfile.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { User as UserIcon, LogOut, Eye } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { SafeImage } from "../ui/SafeImage";
|
||||
import { getViewAsStatusSync } from "@/lib/admin-api";
|
||||
|
||||
const ROLE_DISPLAY_NAMES: Record<string, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
EXECUTIVE: 'Executive',
|
||||
TENANT_ADMIN: 'Tenant Admin',
|
||||
SOC_ANALYST: 'SOC Analyst',
|
||||
ENGINEER: 'Engineer',
|
||||
AGENT_VIEWER: 'Agent Viewer',
|
||||
// Company-facing roles — client/customer accounts
|
||||
COMPANY_ADMIN: 'Company Admin',
|
||||
COMPANY_OPERATOR: 'Company Operator',
|
||||
COMPANY_VIEWER: 'Company Viewer',
|
||||
};
|
||||
|
||||
const getRoleDisplayName = (role?: string) => {
|
||||
if (!role) return 'Standard User';
|
||||
return ROLE_DISPLAY_NAMES[role] || 'Standard User';
|
||||
};
|
||||
|
||||
interface ProfileProps {
|
||||
user: any;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
|
||||
const [viewAsStatus, setViewAsStatus] = useState<any | null>(null);
|
||||
const accountDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (accountDropdownRef.current && !accountDropdownRef.current.contains(event.target as Node)) {
|
||||
setIsAccountDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Cek apakah sedang dalam mode View-As untuk menampilkan label yang benar
|
||||
const status = getViewAsStatusSync();
|
||||
setViewAsStatus(status);
|
||||
}, []);
|
||||
|
||||
// Jika dalam View-As mode, gunakan label dari view-as (bukan role dari server yang jadi AGENT_VIEWER)
|
||||
const isViewAsMode = viewAsStatus?.active;
|
||||
|
||||
// Bangun label yang ditampilkan di sidebar bawah profil
|
||||
const roleLabel = (() => {
|
||||
if (!isViewAsMode) return getRoleDisplayName(user?.role);
|
||||
|
||||
const viewAsType = viewAsStatus?.view_as_type;
|
||||
if (viewAsType === 'user') {
|
||||
const userName = viewAsStatus?.user_name || 'Unknown';
|
||||
const userRole = viewAsStatus?.user_role || '';
|
||||
const roleShort = ROLE_DISPLAY_NAMES[userRole] || userRole.replace(/_/g, ' ');
|
||||
return `Viewing as: ${userName} (${roleShort})`;
|
||||
}
|
||||
// Direct agent view-as: tampilkan label agent
|
||||
const agentLabel = viewAsStatus?.agent_label || viewAsStatus?.agent_uuid || 'Unknown';
|
||||
return `Viewing Agent: ${agentLabel}`;
|
||||
})();
|
||||
|
||||
const initial = user?.account_name
|
||||
? user.account_name.charAt(0)
|
||||
: (user?.username ? user.username.charAt(0) : 'U');
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0" ref={accountDropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsAccountDropdownOpen(!isAccountDropdownOpen)}
|
||||
className="flex w-full items-center gap-3 rounded-xl p-2 hover:bg-secondary/50 transition-colors"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden">
|
||||
{user?.profile_picture ? (
|
||||
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold text-white uppercase text-xs">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start overflow-hidden text-left min-w-0 flex-1">
|
||||
<span className="text-sm font-semibold text-foreground truncate w-full">
|
||||
{user?.account_name || user?.username || 'Loading...'}
|
||||
</span>
|
||||
<span className={`text-[10px] truncate w-full ${isViewAsMode ? 'text-amber-400' : 'text-primary'}`}>
|
||||
{isViewAsMode && <Eye className="inline w-2.5 h-2.5 mr-0.5" />}
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isAccountDropdownOpen && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-60 lg:bottom-0 lg:left-full lg:ml-4 rounded-xl border border-border bg-card/95 backdrop-blur-xl p-2 shadow-2xl z-50 animate-fade-in">
|
||||
<div className="flex flex-col items-center justify-center p-3 mb-2 border-b border-border/50">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden mb-2">
|
||||
{user?.profile_picture ? (
|
||||
<SafeImage src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="font-bold text-white uppercase text-lg">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-foreground text-center break-words w-full leading-tight">
|
||||
{user?.account_name || user?.username || 'Loading...'}
|
||||
</span>
|
||||
{isViewAsMode ? (
|
||||
<span className="text-xs text-amber-400 mt-1 text-center font-medium flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
{(() => {
|
||||
const vt = viewAsStatus?.view_as_type;
|
||||
if (vt === 'user') {
|
||||
const un = viewAsStatus?.user_name || 'Unknown';
|
||||
const ur = ROLE_DISPLAY_NAMES[viewAsStatus?.user_role] || viewAsStatus?.user_role?.replace(/_/g, ' ') || '';
|
||||
return `${un} (${ur})`;
|
||||
}
|
||||
return viewAsStatus?.agent_label || viewAsStatus?.agent_uuid || 'Unknown';
|
||||
})()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-primary mt-1 text-center font-medium">
|
||||
{getRoleDisplayName(user?.role)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isViewAsMode && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAccountDropdownOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" /> Account Settings
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border/50 my-1 mx-2" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" /> Sign Out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Activity, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarSiteSwitcherProps {
|
||||
user: { role?: string; site_uuid?: string | null } | null;
|
||||
selectedSite: string;
|
||||
isSiteDropdownOpen: boolean;
|
||||
setIsSiteDropdownOpen: (open: boolean) => void;
|
||||
siteDropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
handleSiteSelect: (val: string) => void;
|
||||
getSiteLabel: (uuid: string) => string;
|
||||
}
|
||||
|
||||
export function SidebarSiteSwitcher({
|
||||
user,
|
||||
selectedSite,
|
||||
isSiteDropdownOpen,
|
||||
setIsSiteDropdownOpen,
|
||||
siteDropdownRef,
|
||||
handleSiteSelect,
|
||||
getSiteLabel,
|
||||
}: SidebarSiteSwitcherProps) {
|
||||
const isGlobalUser = user?.role === 'SUPER_ADMIN' ||
|
||||
user?.role === 'EXECUTIVE' ||
|
||||
((!user?.site_uuid || user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(user?.role || ''));
|
||||
|
||||
if (!user || !isGlobalUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full px-2" ref={siteDropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsSiteDropdownOpen(!isSiteDropdownOpen)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.05] p-2.5 text-xs text-slate-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Activity className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
<div className="flex flex-col items-start truncate leading-normal">
|
||||
<span className="font-semibold text-[10px] text-primary uppercase tracking-wider">Active Site</span>
|
||||
<span className="truncate max-w-[130px]">{getSiteLabel(selectedSite)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-300", isSiteDropdownOpen && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-2 right-2 mt-1 rounded-xl border border-white/10 bg-slate-950 p-1.5 shadow-2xl z-50 origin-top transition-all duration-300 ease-out",
|
||||
isSiteDropdownOpen
|
||||
? "opacity-100 scale-100 pointer-events-auto translate-y-0"
|
||||
: "opacity-0 scale-95 pointer-events-none -translate-y-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => handleSiteSelect('6681452d_9cae_4ff4_8ae8_0d504774265e')}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
selectedSite === '6681452d_9cae_4ff4_8ae8_0d504774265e'
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
SIAB
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSiteSelect('1959bb55_045b_47c7_bbdd_f33b7db197b9')}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
selectedSite === '1959bb55_045b_47c7_bbdd_f33b7db197b9'
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
Office
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Calendar, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TimeOption {
|
||||
readonly value: "5m" | "30m" | "1h" | "1d" | "7d";
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
interface SidebarTimeFilterProps {
|
||||
timeRange: string;
|
||||
setTimeRange: (range: any) => void;
|
||||
isTimeDropdownOpen: boolean;
|
||||
setIsTimeDropdownOpen: (open: boolean) => void;
|
||||
timeDropdownRef: React.RefObject<HTMLDivElement | null>;
|
||||
selectedTimeOption: TimeOption;
|
||||
timeOptions: readonly TimeOption[];
|
||||
}
|
||||
|
||||
export function SidebarTimeFilter({
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
isTimeDropdownOpen,
|
||||
setIsTimeDropdownOpen,
|
||||
timeDropdownRef,
|
||||
selectedTimeOption,
|
||||
timeOptions,
|
||||
}: SidebarTimeFilterProps) {
|
||||
return (
|
||||
<div className="relative w-full px-2 pb-2" ref={timeDropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsTimeDropdownOpen(!isTimeDropdownOpen)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.05] p-2.5 text-xs text-slate-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Calendar className="h-3.5 w-3.5 text-primary shrink-0" />
|
||||
<div className="flex flex-col items-start truncate leading-normal">
|
||||
<span className="font-semibold text-[10px] text-primary uppercase tracking-wider">Time Filter</span>
|
||||
<span className="truncate max-w-[130px]">{selectedTimeOption.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-300", isTimeDropdownOpen && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-2 right-2 mt-1 rounded-xl border border-white/10 bg-slate-950 p-1.5 shadow-2xl z-50 origin-top transition-all duration-300 ease-out",
|
||||
isTimeDropdownOpen
|
||||
? "opacity-100 scale-100 pointer-events-auto translate-y-0"
|
||||
: "opacity-0 scale-95 pointer-events-none -translate-y-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{timeOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setTimeRange(opt.value);
|
||||
setIsTimeDropdownOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
timeRange === opt.value
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 422 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user