feat(site): implement dynamic site selection dropdown in sidebar and enable multi-site monitoring
This commit is contained in:
1 parent
be6bc14190
commit
4882108068
125 files changed
+5666
-4305
No files matched your search
@@ -152,3 +152,76 @@ export function getAgentColumns({
|
||||
|
||||
return cols;
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<img src={`/api/uploads/${row.profile_picture}`} alt={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 === '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">
|
||||
{/* Defaulting to active since we don't have is_active explicitly in ManagedUser interface yet, though API returns it. We'll check if it exists, else assume Active */}
|
||||
{(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>
|
||||
)
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -6,12 +6,14 @@ import { getAgents, Agent } from "@/lib/actions/agents";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { Loader2, Plus, Server, CheckCircle2, XCircle, Activity } from "lucide-react";
|
||||
import { Cpu, History, Radio, Server, Activity, Plus, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
|
||||
import { getAdminUsers, startViewAs, resolveAgentLabel, getViewAsHeaders, type ManagedUser } from "@/lib/admin-api";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { usePollingKey } from "@/lib/usePolling";
|
||||
import { AgentModals } from "@/components/admin/AgentModals";
|
||||
import { getAgentColumns } from "./columns";
|
||||
import ExternalAccountModal from "@/components/admin/ExternalAccountModal";
|
||||
import { getAgentColumns, getExternalAccountColumns } from "./columns";
|
||||
|
||||
export default function AgentsPage() {
|
||||
const router = useRouter();
|
||||
@@ -29,9 +31,13 @@ export default function AgentsPage() {
|
||||
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 [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 {
|
||||
@@ -64,8 +70,9 @@ export default function AgentsPage() {
|
||||
const loadAgents = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const siteUuid = localStorage.getItem("backone_site_uuid") || undefined;
|
||||
const [agentData, userData] = await Promise.allSettled([
|
||||
getAgents(),
|
||||
getAgents(siteUuid),
|
||||
getAdminUsers(),
|
||||
]);
|
||||
if (agentData.status === 'fulfilled') setAgents(agentData.value);
|
||||
@@ -122,6 +129,19 @@ export default function AgentsPage() {
|
||||
}
|
||||
}, [role]);
|
||||
|
||||
useEffect(() => {
|
||||
if (role && role !== 'AGENT_VIEWER' && refreshKey > 0) {
|
||||
const siteUuid = localStorage.getItem("backone_site_uuid") || undefined;
|
||||
getAgents(siteUuid)
|
||||
.then(data => setAgents(data))
|
||||
.catch(err => console.warn("Failed to auto-refresh agents:", err));
|
||||
loadUptime();
|
||||
if (role === 'SUPER_ADMIN') {
|
||||
loadStorage();
|
||||
}
|
||||
}
|
||||
}, [refreshKey, role]);
|
||||
|
||||
const openDelete = (agent: Agent) => {
|
||||
setSelectedAgent(agent);
|
||||
setIsDeleteOpen(true);
|
||||
@@ -146,6 +166,12 @@ export default function AgentsPage() {
|
||||
const uptimeValues = agents.map(a => uptimeMap[a.uuid || a.serial] ?? 100);
|
||||
const avgUptime = uptimeValues.length > 0 ? uptimeValues.reduce((s, v) => s + v, 0) / uptimeValues.length : 100;
|
||||
|
||||
const externalUsers = managedUsers.filter(u => u.role !== 'AGENT_VIEWER');
|
||||
const externalColumns = getExternalAccountColumns((user) => {
|
||||
setExternalModalUser(user);
|
||||
setExternalModalOpen(true);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -230,6 +256,45 @@ export default function AgentsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{(role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN') && (
|
||||
<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); // Mode Create
|
||||
setExternalModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold bg-amber-500/20 text-amber-400 hover:bg-amber-500/30 border border-amber-500/30 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add External Account
|
||||
</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>
|
||||
)}
|
||||
|
||||
<AgentModals
|
||||
isCreateOpen={isCreateOpen}
|
||||
setIsCreateOpen={setIsCreateOpen}
|
||||
@@ -245,6 +310,13 @@ export default function AgentsPage() {
|
||||
setAccountModalAgent={setAccountModalAgent}
|
||||
managedUsers={managedUsers}
|
||||
/>
|
||||
|
||||
<ExternalAccountModal
|
||||
isOpen={externalModalOpen}
|
||||
onClose={() => { setExternalModalOpen(false); setExternalModalUser(null); }}
|
||||
onSuccess={loadAgents}
|
||||
user={externalModalUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
"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 { fmtBytes, getAppFavicon } from "@/lib/utils";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||
import { fmtBytes, getAppFavicon, formatAppLabel } from "@/lib/utils";
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
export default function AppsPage() {
|
||||
@@ -17,16 +19,74 @@ export default function AppsPage() {
|
||||
const { data, isLoading } = useTopApps(pageSize);
|
||||
const [selectedApp, setSelectedApp] = useState<AppStat | null>(null);
|
||||
|
||||
const {
|
||||
filters, dateFrom, dateTo,
|
||||
handleFilterChange, setDateFrom, setDateTo,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const top5 = data.slice(0, 5);
|
||||
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) => row.last_seen ? new Date(row.last_seen).toLocaleString() : '-' },
|
||||
{
|
||||
header: "Last Seen",
|
||||
accessor: (row) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{
|
||||
header: "Application",
|
||||
@@ -46,7 +106,7 @@ export default function AppsPage() {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<span className="group-hover:text-blue-300 transition-colors">{row.app_label}</span>
|
||||
<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>
|
||||
),
|
||||
@@ -58,11 +118,11 @@ export default function AppsPage() {
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>,
|
||||
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>,
|
||||
accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.download + row.upload)}</span>
|
||||
},
|
||||
];
|
||||
|
||||
@@ -80,6 +140,34 @@ export default function AppsPage() {
|
||||
</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>
|
||||
@@ -115,11 +203,11 @@ export default function AppsPage() {
|
||||
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">All Applications ({data.length})</CardTitle>
|
||||
<CardTitle className="text-white">All Applications ({processedData.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={data}
|
||||
data={processedData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search by application or category..."
|
||||
searchFilter={(row, query) => {
|
||||
|
||||
@@ -4,74 +4,24 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { DeviceStat } from "@/lib/api";
|
||||
import { useDevices } from "@/lib/api-with-context";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
const FilterSelect = ({ value, onChange, options, placeholder }: { value: string; onChange: (val: string) => void; options: string[]; placeholder: string }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const isActive = value !== "All";
|
||||
const displayValue = placeholder;
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[140px] mx-auto flex justify-center" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items-center justify-center gap-1.5 w-full py-1 font-medium text-sm transition-colors outline-none ${isActive ? 'text-primary' : 'text-muted-foreground hover:text-white'}`}
|
||||
>
|
||||
<span className="truncate max-w-[110px]">{displayValue}</span>
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform duration-500 flex-shrink-0 ${isOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
|
||||
{/* Animated Dropdown Menu */}
|
||||
<div
|
||||
className={`absolute top-full left-1/2 -translate-x-1/2 mt-2 w-[200px] max-h-[300px] overflow-y-auto bg-slate-900 border border-border/50 rounded-lg shadow-xl z-50 flex flex-col py-1 transition-all duration-500 ease-in-out origin-top ${isOpen ? "opacity-100 scale-y-100 translate-y-0 pointer-events-auto" : "opacity-0 scale-y-90 -translate-y-2 pointer-events-none"}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => { onChange("All"); setIsOpen(false); }}
|
||||
className={`px-3 py-2 text-sm text-left whitespace-normal break-words transition-colors w-full ${value === "All" ? "bg-primary/20 text-white font-medium" : "text-slate-300 hover:bg-white/5 hover:text-white"}`}
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => { onChange(opt); setIsOpen(false); }}
|
||||
className={`px-3 py-2 text-sm text-left whitespace-normal break-words transition-colors w-full ${value === opt ? "bg-primary/20 text-white font-medium" : "text-slate-300 hover:bg-white/5 hover:text-white"}`}
|
||||
title={opt}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const devices = useDevices(5000);
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
||||
|
||||
const [filterMan, setFilterMan] = useState<string>("All");
|
||||
const [filterType, setFilterType] = useState<string>("All");
|
||||
const [filterOS, setFilterOS] = useState<string>("All");
|
||||
|
||||
const [sortDownload, setSortDownload] = useState<string>("All");
|
||||
const [sortUpload, setSortUpload] = useState<string>("All");
|
||||
const {
|
||||
filters, dateFrom, dateTo,
|
||||
handleFilterChange, setDateFrom, setDateTo,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -79,24 +29,26 @@ export default function DevicesPage() {
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const manufacturers = Array.from(new Set(devices.data?.map(d => d.manufacturer).filter(Boolean) || [])).filter(v => v !== "-").sort() as string[];
|
||||
const deviceTypes = Array.from(new Set(devices.data?.map(d => d.device_type).filter(Boolean) || [])).filter(v => v !== "-").sort() as string[];
|
||||
const osLabels = Array.from(new Set(devices.data?.map(d => d.os_label).filter(Boolean) || [])).filter(v => v !== "-").sort() as string[];
|
||||
const all = devices.data || [];
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "device_type", label: "Device Type", type: "select", value: filters.device_type, options: opts("device_type") },
|
||||
{ id: "os_label", label: "OS", type: "select", value: filters.os_label, options: opts("os_label") },
|
||||
{ id: "manufacturer", label: "Manufacturer", type: "select", value: filters.manufacturer, options: opts("manufacturer") },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const columns: Column<DeviceStat>[] = [
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[13%]",
|
||||
accessor: (row) => {
|
||||
const dateStr = row.last_seen ? new Date(row.last_seen) : null;
|
||||
if (!dateStr) return "-";
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<span className="text-sm font-medium">{dateStr.toLocaleDateString()}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{dateStr.toLocaleTimeString()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
accessor: (row) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
@@ -148,39 +100,64 @@ export default function DevicesPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
header: <FilterSelect value={filterType} onChange={setFilterType} options={deviceTypes} placeholder="Type" />,
|
||||
header: "Type",
|
||||
accessor: (row) => row.device_type || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: <FilterSelect value={filterOS} onChange={setFilterOS} options={osLabels} placeholder="OS" />,
|
||||
header: "OS",
|
||||
accessor: (row) => row.os_label || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: <FilterSelect value={filterMan} onChange={setFilterMan} options={manufacturers} placeholder="Manufacturer" />,
|
||||
header: "Manufacturer",
|
||||
accessor: (row) => row.manufacturer || "-",
|
||||
className: "w-[14%]"
|
||||
},
|
||||
{
|
||||
header: <FilterSelect value={sortDownload} onChange={(val) => { setSortDownload(val); setSortUpload("All"); }} options={["Descending", "Ascending"]} placeholder="Download" />,
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
{
|
||||
header: <FilterSelect value={sortUpload} onChange={(val) => { setSortUpload(val); setSortDownload("All"); }} options={["Descending", "Ascending"]} placeholder="Upload" />,
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
];
|
||||
|
||||
let processedData = [...(devices.data || [])];
|
||||
const filteredData = all.filter((row: any) => {
|
||||
if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false;
|
||||
if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false;
|
||||
if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false;
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : "";
|
||||
if (dateFrom && d < dateFrom) return false;
|
||||
if (dateTo && d > dateTo) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const minTime = all.reduce((min: number, r: any) => {
|
||||
if (r.last_seen) {
|
||||
const time = new Date(r.last_seen).getTime();
|
||||
if (isFinite(time) && time < min) return time;
|
||||
}
|
||||
return min;
|
||||
}, Infinity);
|
||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -196,11 +173,31 @@ export default function DevicesPage() {
|
||||
</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/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">
|
||||
Active Devices {devices.data ? `(${devices.data.length})` : ""}
|
||||
</CardTitle>
|
||||
<CardTitle className="text-white">Active Devices ({processedData.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
@@ -216,11 +213,7 @@ export default function DevicesPage() {
|
||||
(row.device_type || "").toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
const matchesMan = filterMan === "All" || row.manufacturer === filterMan;
|
||||
const matchesType = filterType === "All" || row.device_type === filterType;
|
||||
const matchesOS = filterOS === "All" || row.os_label === filterOS;
|
||||
|
||||
return matchesSearch && matchesMan && matchesType && matchesOS;
|
||||
return matchesSearch;
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `devices-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
|
||||
@@ -1,32 +1,95 @@
|
||||
"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";
|
||||
|
||||
export default function DNSPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { data, isLoading } = useDNS(100); // fetch 100 DNS queries
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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: "Application", accessor: (row) => row.app_label || "-" },
|
||||
{ header: "Category", accessor: (row) => row.category || "-" },
|
||||
{
|
||||
header: "Query Count",
|
||||
accessor: (row) => <span className="text-purple-400">{row.query_count.toLocaleString()}</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>
|
||||
}
|
||||
];
|
||||
|
||||
@@ -39,16 +102,24 @@ export default function DNSPage() {
|
||||
</h2>
|
||||
</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">Top DNS Queries ({data.length})</CardTitle>
|
||||
<CardTitle className="text-white">All DNS Queries</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={data}
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search by domain, application, or category..."
|
||||
onRowClick={(row) => setSelectedDomain(row.domain)}
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
@@ -60,6 +131,67 @@ export default function DNSPage() {
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { MetadataDetailPanel } from "@/components/dpi/MetadataDetailPanel";
|
||||
import { MetadataDetailType } from "@/lib/api-metadata-detail";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
|
||||
|
||||
// Clickable hint badge shown in table column headers when rows are interactive
|
||||
function ClickHint() {
|
||||
@@ -80,7 +80,18 @@ export default function DpiAnalyticsPage() {
|
||||
isLoading={loadingSni}
|
||||
onRowClick={(row: any) => openDetail("sni_hostname", row.sni_hostname)}
|
||||
columns={[
|
||||
{ header: "Hostname", accessor: (row: any) => row.sni_hostname },
|
||||
{
|
||||
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) },
|
||||
]}
|
||||
/>
|
||||
@@ -95,7 +106,18 @@ export default function DpiAnalyticsPage() {
|
||||
isLoading={loadingSslCn}
|
||||
onRowClick={(row: any) => openDetail("ssl_server_cn", row.ssl_server_cn)}
|
||||
columns={[
|
||||
{ header: "Common Name", accessor: (row: any) => row.ssl_server_cn },
|
||||
{
|
||||
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) },
|
||||
]}
|
||||
/>
|
||||
@@ -110,7 +132,18 @@ export default function DpiAnalyticsPage() {
|
||||
isLoading={loadingQuic}
|
||||
onRowClick={(row: any) => openDetail("quic_hostname", row.quic_hostname)}
|
||||
columns={[
|
||||
{ header: "Hostname", accessor: (row: any) => row.quic_hostname },
|
||||
{
|
||||
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) },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
"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 { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
// Helper: format UTC ISO timestamp → Waktu WIB (UTC+7) formatted in English locale
|
||||
function formatTimestampWIB(ts: string): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString('en-US', {
|
||||
timeZone: 'Asia/Jakarta',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function EventsContent() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -36,12 +22,55 @@ function EventsContent() {
|
||||
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':
|
||||
@@ -59,11 +88,7 @@ function EventsContent() {
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
|
||||
{
|
||||
header: "Timestamp (WIB)",
|
||||
accessor: (row) => (
|
||||
<span className="font-mono text-xs text-slate-300 whitespace-nowrap">
|
||||
{formatTimestampWIB(row.timestamp)}
|
||||
</span>
|
||||
),
|
||||
accessor: (row) => <TimestampCell timestamp={row.timestamp} showActiveStatus={false} />,
|
||||
className: "w-[180px] min-w-[180px] max-w-[180px]"
|
||||
},
|
||||
{
|
||||
@@ -139,13 +164,25 @@ function EventsContent() {
|
||||
</h2>
|
||||
</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 ({data.length})</CardTitle>
|
||||
<CardTitle className="text-white">Recent Events ({filteredData.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={data}
|
||||
data={filteredData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search by event type, severity, MAC, IP, or message..."
|
||||
searchFilter={(row, query) => {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { useFlows } 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 { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||
|
||||
function explainAppOrPort(appLabel: string | null, domain: string | null, port: number) {
|
||||
const label = appLabel || domain || "";
|
||||
@@ -19,7 +21,7 @@ function explainAppOrPort(appLabel: string | null, domain: string | null, port:
|
||||
"3306": "MySQL/MariaDB Database Server",
|
||||
"5432": "PostgreSQL Database Server",
|
||||
"1521": "Oracle Database Server",
|
||||
"27017": "MongoDB Database Server",
|
||||
"27017": "Database Server",
|
||||
"6379": "Redis Key-Value Cache",
|
||||
"80": "Unencrypted Web Traffic (HTTP)",
|
||||
"443": "Encrypted Web Traffic (HTTPS/TLS)",
|
||||
@@ -60,6 +62,12 @@ export default function FlowsPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [selectedIp, setSelectedIp] = useState<string | null>(null);
|
||||
const { data, isLoading } = useFlows();
|
||||
|
||||
const {
|
||||
filters, dateFrom, dateTo,
|
||||
handleFilterChange, setDateFrom, setDateTo,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -67,7 +75,79 @@ export default function FlowsPage() {
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const all = data || [];
|
||||
|
||||
// Extract unique options
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ 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"] },
|
||||
];
|
||||
|
||||
const filteredData = all.filter((row: any) => {
|
||||
if (filters.protocol && filters.protocol !== "All" && row.protocol !== filters.protocol) return false;
|
||||
if (filters.src_ip && filters.src_ip !== "All" && row.src_ip !== filters.src_ip) return false;
|
||||
if (filters.dst_ip && filters.dst_ip !== "All" && row.dst_ip !== filters.dst_ip) return false;
|
||||
if (filters.dst_port && filters.dst_port !== "All" && String(row.dst_port) !== filters.dst_port) return false;
|
||||
if (filters.app && filters.app !== "All" && row.app_label !== filters.app) return false;
|
||||
if (filters.domain && filters.domain !== "All" && row.domain !== filters.domain) return false;
|
||||
|
||||
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.bytes_upload ?? 0) - (a.bytes_upload ?? 0) : (a.bytes_upload ?? 0) - (b.bytes_upload ?? 0));
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? (b.bytes_download ?? 0) - (a.bytes_download ?? 0) : (a.bytes_download ?? 0) - (b.bytes_download ?? 0));
|
||||
} else {
|
||||
processedData.sort((a, b) => {
|
||||
const timeA = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||||
const timeB = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||||
return timeB - timeA;
|
||||
});
|
||||
}
|
||||
|
||||
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 columns: Column<typeof data[0]>[] = [
|
||||
{
|
||||
header: "Last Seen",
|
||||
accessor: (row) => (
|
||||
<TimestampCell
|
||||
timestamp={row.last_seen}
|
||||
firstSeen={row.first_seen}
|
||||
showActiveStatus={true}
|
||||
/>
|
||||
),
|
||||
className: "w-[120px]"
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1 },
|
||||
{ header: "Flow ID", accessor: (row) => row.flow_id },
|
||||
{
|
||||
@@ -117,15 +197,11 @@ export default function FlowsPage() {
|
||||
},
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>,
|
||||
sortable: true,
|
||||
sortAccessor: (row) => row.bytes_download ?? 0
|
||||
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span>,
|
||||
sortable: true,
|
||||
sortAccessor: (row) => row.bytes_upload ?? 0
|
||||
accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span>
|
||||
}
|
||||
];
|
||||
|
||||
@@ -137,14 +213,36 @@ export default function FlowsPage() {
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
</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>
|
||||
<CardTitle className="text-white">Active Flows ({data.length})</CardTitle>
|
||||
<CardTitle className="text-white">Active Flows ({processedData.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={data}
|
||||
data={processedData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search flows by IP, App, or Domain..."
|
||||
searchFilter={(row, query) => {
|
||||
|
||||
@@ -7,27 +7,64 @@ 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";
|
||||
|
||||
export default function GeographyPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const { data, isLoading } = useCountries(50); // fetch 50 countries
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
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);
|
||||
}, []);
|
||||
|
||||
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 },
|
||||
{
|
||||
@@ -75,6 +112,23 @@ export default function GeographyPage() {
|
||||
</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' }}>
|
||||
@@ -96,7 +150,7 @@ export default function GeographyPage() {
|
||||
|
||||
<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 ({data.length})</CardTitle>
|
||||
<CardTitle className="text-lg text-white">Traffic by Country ({processedData.length})</CardTitle>
|
||||
{!showMap && (
|
||||
<button
|
||||
onClick={() => setShowMap(true)}
|
||||
@@ -109,7 +163,7 @@ export default function GeographyPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={data}
|
||||
data={processedData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search by country name..."
|
||||
searchFilter={(row, query) => {
|
||||
|
||||
@@ -10,12 +10,16 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Network, ShieldAlert } from "lucide-react";
|
||||
import { Loader2, Bitcoin, Shield, Server, Lock, AlertTriangle, Key, Globe, Network, ShieldAlert, ChevronRight } from "lucide-react";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
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
|
||||
const { data: cryptoMining, isLoading: loadingCrypto } = useIntelCryptoMining(20);
|
||||
@@ -183,10 +187,74 @@ export default function IntelligencePage() {
|
||||
return (
|
||||
<DataTable
|
||||
data={encryptionAudit} isLoading={loadingEncryption}
|
||||
searchPlaceholder="Search by Device or IP..."
|
||||
searchFilter={(row: any, q: string) => {
|
||||
const query = q.toLowerCase();
|
||||
return (row.ip_address || '').toLowerCase().includes(query) || (row.device_label || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `encryption-audit-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["Device Label", "MAC Address", "IP Address", "Encrypted Data", "Encrypted %", "Total Data", "Risk Level"],
|
||||
rowSerializer: (row: any) => [row.device_label || "", row.mac_address || "", row.ip_address || "", row.encrypted || 0, row.encrypted_pct || 0, row.total || 0, row.risk_level || ""]
|
||||
}}
|
||||
columns={[
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "Encrypted %", accessor: (row) => `${row.encrypted_pct}%` },
|
||||
{ header: "Risk Level", accessor: (row: any) => row.risk_level },
|
||||
{
|
||||
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>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -194,11 +262,55 @@ export default function IntelligencePage() {
|
||||
return (
|
||||
<DataTable
|
||||
data={deviceDiscovery} isLoading={loadingDevice}
|
||||
searchPlaceholder="Search by IP, MAC, OS..."
|
||||
searchFilter={(row: any, q: string) => {
|
||||
const query = q.toLowerCase();
|
||||
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query) || (row.device_type || '').toLowerCase().includes(query);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `device-discovery-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "MAC Address", "Device Type", "OS", "Manufacturer", "Download", "Upload", "Last Seen"],
|
||||
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.device_type || "", row.os_label || "", row.manufacturer || "", row.download || 0, row.upload || 0, row.last_seen || ""]
|
||||
}}
|
||||
columns={[
|
||||
{ header: "IP Address", accessor: (row: any) => row.ip_address },
|
||||
{ header: "Device Type", accessor: (row: any) => row.device_type },
|
||||
{ header: "OS", accessor: (row: any) => row.os_label },
|
||||
{ header: "Manufacturer", accessor: (row: any) => row.manufacturer },
|
||||
{
|
||||
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>
|
||||
) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -206,19 +318,49 @@ export default function IntelligencePage() {
|
||||
return (
|
||||
<DataTable
|
||||
data={serverDiscovery} isLoading={loadingServer}
|
||||
searchPlaceholder="Search by IP, MAC, Type..."
|
||||
searchFilter={(row: any, q: string) => {
|
||||
const query = q.toLowerCase();
|
||||
return (row.ip_address || '').toLowerCase().includes(query) || (row.mac_address || '').toLowerCase().includes(query) || (row.server_type || '').toLowerCase().includes(query) || (row.os_label || '').toLowerCase().includes(query);
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `server-discovery-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "MAC Address", "Server Type", "Port", "OS"],
|
||||
rowSerializer: (row: any) => [row.ip_address || "", row.mac_address || "", row.server_type || "", row.port || "", row.os_label || ""]
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
header: "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: "Last Seen",
|
||||
className: "w-[15%]",
|
||||
accessor: (row: any) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "Server Type", accessor: (row: any) => row.server_type },
|
||||
{ header: "Port", accessor: (row: any) => row.port },
|
||||
{ header: "OS", accessor: (row: any) => row.os_label },
|
||||
{ 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 },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
@@ -273,6 +415,14 @@ export default function IntelligencePage() {
|
||||
{renderActiveTable()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedDevice && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip}
|
||||
mac={selectedDevice.mac}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useLookupApplications, LookupApp } from "@/lib/api";
|
||||
import { useLookupApplications, useLookupCategories, LookupApp } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Loader2, Search, AppWindow, HelpCircle, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||
import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect";
|
||||
import { Loader2, Search, AppWindow, HelpCircle, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X, Info, Tag, Box, Shield, Server } from "lucide-react";
|
||||
|
||||
export default function LookupPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedApp, setSelectedApp] = useState<LookupApp | null>(null);
|
||||
const [isClosingModal, setIsClosingModal] = useState(false);
|
||||
const limit = 24; // 24 items per page fits a 2, 3, or 4 column grid perfectly
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsClosingModal(true);
|
||||
setTimeout(() => {
|
||||
setSelectedApp(null);
|
||||
setIsClosingModal(false);
|
||||
}, 480);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
@@ -26,7 +38,14 @@ export default function LookupPage() {
|
||||
return () => clearTimeout(handler);
|
||||
}, [searchQuery]);
|
||||
|
||||
const { data, isLoading, error } = useLookupApplications(page, limit, debouncedSearch);
|
||||
const { data, isLoading, error } = useLookupApplications(page, limit, debouncedSearch, selectedCategory);
|
||||
const { data: categories, error: categoriesError } = useLookupCategories();
|
||||
|
||||
useEffect(() => {
|
||||
if (categoriesError) {
|
||||
console.error("Failed to fetch categories:", categoriesError);
|
||||
}
|
||||
}, [categoriesError]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
@@ -45,16 +64,30 @@ export default function LookupPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search Input Bar */}
|
||||
<div className="relative max-w-md w-full animate-slide-up" style={{ animationDelay: '50ms' }}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by app name or tag (e.g. facebook, openvpn)..."
|
||||
className="w-full bg-card/60 backdrop-blur-xl border border-border/60 rounded-xl pl-10 pr-4 py-3 text-white placeholder-slate-400 focus:outline-none focus:border-primary/80 focus:ring-1 focus:ring-primary/80 transition-all duration-200"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{/* Search & Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 animate-slide-up relative z-50" style={{ animationDelay: '50ms' }}>
|
||||
<div className="relative flex-1 max-w-md w-full">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by app name or tag (e.g. facebook, openvpn)..."
|
||||
className="w-full bg-slate-900 border border-border/50 rounded-lg pl-10 pr-4 py-2 text-sm text-white placeholder-slate-400 focus:outline-none hover:border-slate-700 transition-all duration-200"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:w-64 z-50">
|
||||
<DropdownFilterSelect
|
||||
value={selectedCategory || "All"}
|
||||
onChange={(val) => {
|
||||
setSelectedCategory(val === "All" ? "" : val);
|
||||
setPage(1); // Reset page on category change
|
||||
}}
|
||||
options={categories || []}
|
||||
placeholder="All Categories"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
@@ -75,7 +108,8 @@ export default function LookupPage() {
|
||||
{applications.map((app: LookupApp) => (
|
||||
<Card
|
||||
key={app.id}
|
||||
className="group relative overflow-hidden bg-card/60 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-primary/40 transition-all duration-300 flex flex-col justify-between"
|
||||
onClick={() => setSelectedApp(app)}
|
||||
className="group relative overflow-hidden bg-card/60 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-primary/40 transition-all duration-300 flex flex-col justify-between cursor-pointer"
|
||||
>
|
||||
<div className="p-5 flex flex-col h-full justify-between gap-4">
|
||||
{/* Top: Icon + Title */}
|
||||
@@ -192,6 +226,134 @@ export default function LookupPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Application Detail Modal */}
|
||||
{selectedApp && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6" onClick={handleCloseModal}>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosingModal ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||
/>
|
||||
<div
|
||||
className={`relative w-full max-w-2xl bg-card border border-border rounded-2xl shadow-2xl shadow-black/60 overflow-hidden ${isClosingModal ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
{/* Header Background */}
|
||||
<div className="h-32 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent relative">
|
||||
<button
|
||||
onClick={handleCloseModal}
|
||||
className="absolute top-4 right-4 p-2 bg-black/20 hover:bg-black/40 rounded-full text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 pb-6 sm:px-8 sm:pb-8 relative -mt-16">
|
||||
|
||||
{/* Icon/Logo */}
|
||||
<div className="w-24 h-24 bg-card rounded-2xl border-4 border-card flex items-center justify-center overflow-hidden shadow-xl relative z-10 mb-4 bg-secondary">
|
||||
{selectedApp.logo || selectedApp.favicon ? (
|
||||
<img
|
||||
src={selectedApp.logo || selectedApp.favicon}
|
||||
alt={selectedApp.label}
|
||||
className="w-16 h-16 object-contain"
|
||||
onError={(e) => {
|
||||
const img = e.target as HTMLElement;
|
||||
img.style.display = 'none';
|
||||
const sibling = img.nextSibling as HTMLElement;
|
||||
if (sibling) sibling.style.display = 'block';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<AppWindow className="w-12 h-12 text-slate-400" style={{ display: (selectedApp.logo || selectedApp.favicon) ? 'none' : 'block' }} />
|
||||
</div>
|
||||
|
||||
{/* Title & Category */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-white tracking-tight">{selectedApp.label}</h2>
|
||||
{selectedApp.application_category?.label && (
|
||||
<span className="px-3 py-1 bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-widest rounded-full">
|
||||
{selectedApp.application_category.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedApp.full_name && selectedApp.full_name !== selectedApp.label && (
|
||||
<p className="text-slate-400 font-medium">{selectedApp.full_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-4 md:col-span-2">
|
||||
<div className="flex items-start gap-3 text-slate-300 bg-secondary/30 p-4 rounded-xl border border-border/30">
|
||||
<Info className="w-5 h-5 text-primary shrink-0 mt-0.5" />
|
||||
<p className="text-sm leading-relaxed">
|
||||
{selectedApp.description || "No detailed description provided for this application or service."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Info */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">System Metadata</h3>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Box className="w-4 h-4" />
|
||||
<span className="text-sm">App ID</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Tag className="w-4 h-4" />
|
||||
<span className="text-sm">System Name</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.name || 'N/A'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="text-sm">Tag / Alias</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.tag || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Application Category Detail */}
|
||||
{selectedApp.application_category && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs uppercase font-bold text-slate-500 tracking-wider">Category Info</h3>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Server className="w-4 h-4" />
|
||||
<span className="text-sm">Category ID</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-secondary/20 rounded-lg border border-border/20">
|
||||
<div className="flex items-center gap-2 text-slate-400">
|
||||
<Info className="w-4 h-4" />
|
||||
<span className="text-sm">Category Tag</span>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-white bg-secondary/50 px-2 py-0.5 rounded">{selectedApp.application_category.tag}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
useFlowTypes, useFlowOrigins
|
||||
} from "@/lib/api-advanced";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal";
|
||||
|
||||
export default function NetworkInfrastructurePage() {
|
||||
const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null);
|
||||
|
||||
// Routing & Geography Tab
|
||||
const { data: regions, isLoading: loadingRegions } = useRegions(20);
|
||||
const { data: cities, isLoading: loadingCities } = useCities(20);
|
||||
@@ -56,8 +59,8 @@ export default function NetworkInfrastructurePage() {
|
||||
data={regions}
|
||||
isLoading={loadingRegions}
|
||||
columns={[
|
||||
{ header: "Region", accessor: (row: any) => row.region_name },
|
||||
{ header: "Country", accessor: (row: any) => row.country_name },
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
@@ -72,9 +75,9 @@ export default function NetworkInfrastructurePage() {
|
||||
data={cities}
|
||||
isLoading={loadingCities}
|
||||
columns={[
|
||||
{ header: "City", accessor: (row: any) => row.city_name },
|
||||
{ header: "Region", accessor: (row: any) => row.region_name },
|
||||
{ header: "Country", accessor: (row: any) => row.country_name },
|
||||
{ header: "City", accessor: (row: any) => <span title={row.city_name}>{row.city_name}</span> },
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
@@ -135,6 +138,7 @@ export default function NetworkInfrastructurePage() {
|
||||
<DataTable
|
||||
data={remoteIps}
|
||||
isLoading={loadingRemoteIps}
|
||||
onRowClick={(row) => setSelectedRemoteIp(row.remote_ip)}
|
||||
columns={[
|
||||
{
|
||||
header: "IP Address",
|
||||
@@ -179,8 +183,8 @@ export default function NetworkInfrastructurePage() {
|
||||
data={macBandwidth}
|
||||
isLoading={loadingMacBandwidth}
|
||||
columns={[
|
||||
{ header: "MAC Address", accessor: (row: any) => row.mac_address },
|
||||
{ header: "Manufacturer", accessor: (row: any) => row.manufacturer },
|
||||
{ header: "MAC Address", accessor: (row: any) => <span title={row.mac_address}>{row.mac_address}</span> },
|
||||
{ header: "Manufacturer", accessor: (row: any) => <span title={row.manufacturer}>{row.manufacturer}</span> },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
@@ -229,6 +233,13 @@ export default function NetworkInfrastructurePage() {
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{selectedRemoteIp && (
|
||||
<RemoteIpDetailModal
|
||||
ip={selectedRemoteIp}
|
||||
onClose={() => setSelectedRemoteIp(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ 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";
|
||||
|
||||
const COLORS = ['#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#f85149'];
|
||||
|
||||
@@ -18,6 +20,13 @@ 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);
|
||||
@@ -25,13 +34,41 @@ export default function NetworkIntelligencePage() {
|
||||
|
||||
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-[60px] min-w-[60px] max-w-[60px]" },
|
||||
{
|
||||
header: "Category",
|
||||
accessor: (row) => (
|
||||
<div title={row.category_label || '-'} className="font-semibold text-white truncate w-full block text-center cursor-help">
|
||||
{row.category_label || '-'}
|
||||
<span className="border-b border-dashed border-white/30 pb-0.5">{row.category_label || '-'}</span>
|
||||
</div>
|
||||
),
|
||||
className: "w-[180px] min-w-[180px] max-w-[180px]"
|
||||
@@ -53,12 +90,25 @@ export default function NetworkIntelligencePage() {
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Deep packet inspection analytics and geography.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-blue-400 bg-blue-500/10 border border-blue-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(59,130,246,0.1)]">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span>DPI Engine Active</span>
|
||||
</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 */}
|
||||
@@ -67,7 +117,12 @@ export default function NetworkIntelligencePage() {
|
||||
<CardTitle className="text-lg text-white">App Categories List</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable data={appCategories.data || []} columns={appCols} pageSize={50} />
|
||||
<DataTable
|
||||
data={processedData}
|
||||
columns={appCols}
|
||||
pageSize={50}
|
||||
onRowClick={(row) => setSelectedCategory(row.category_label)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -83,7 +138,7 @@ export default function NetworkIntelligencePage() {
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={appCategories.data || []}
|
||||
data={processedData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
@@ -92,7 +147,7 @@ export default function NetworkIntelligencePage() {
|
||||
dataKey="download"
|
||||
nameKey="category_label"
|
||||
>
|
||||
{(appCategories.data || []).map((entry, index) => (
|
||||
{processedData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
@@ -136,6 +191,13 @@ export default function NetworkIntelligencePage() {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCategory && (
|
||||
<CategoryDetailPanel
|
||||
category={selectedCategory}
|
||||
onClose={() => setSelectedCategory(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import SecurityDistribution from "@/components/security-audit/SecurityDistributi
|
||||
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";
|
||||
|
||||
export default function SecurityAuditPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -28,12 +29,27 @@ export default function SecurityAuditPage() {
|
||||
.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 = secDevices.filter((d) => d.risk_level === "Vulnerable").length;
|
||||
const moderateCount = secDevices.filter((d) => d.risk_level === "Moderate").length;
|
||||
const safeCount = secDevices.filter((d) => d.risk_level === "Safe").length;
|
||||
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">
|
||||
@@ -44,6 +60,14 @@ export default function SecurityAuditPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={handleFilterChange}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
{/* Risk Summary Cards */}
|
||||
<RiskSummaryCards
|
||||
vulnerableCount={vulnerableCount}
|
||||
@@ -51,12 +75,6 @@ export default function SecurityAuditPage() {
|
||||
safeCount={safeCount}
|
||||
/>
|
||||
|
||||
{/* Device Risk Table */}
|
||||
<DeviceRiskTable
|
||||
secDevices={secDevices}
|
||||
secDevicesLoading={secDevicesLoading}
|
||||
/>
|
||||
|
||||
{/* Charts Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SecurityDistribution securityData={security.data} />
|
||||
@@ -71,6 +89,12 @@ export default function SecurityAuditPage() {
|
||||
|
||||
{/* SSL/TLS Subject Alternative Names Table */}
|
||||
<SslSanTable />
|
||||
|
||||
{/* Device Risk Table */}
|
||||
<DeviceRiskTable
|
||||
secDevices={filteredDevices}
|
||||
secDevicesLoading={secDevicesLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { useState, useEffect, useMemo, Suspense } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { ShieldCheck, Loader2 } from "lucide-react";
|
||||
import { ThreatStat } from "@/lib/api";
|
||||
import { useThreats } from "@/lib/api-with-context";
|
||||
@@ -89,11 +90,7 @@ function ThreatsContent() {
|
||||
{
|
||||
header: "Timestamp",
|
||||
className: "w-[155px] min-w-[155px] max-w-[155px] text-center",
|
||||
accessor: (row) =>
|
||||
row.detected_at ? new Date(row.detected_at).toLocaleString("id-ID", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit"
|
||||
}) : "-",
|
||||
accessor: (row) => <TimestampCell timestamp={row.detected_at} showActiveStatus={false} />
|
||||
},
|
||||
{ header: "#", className: "w-[40px] min-w-[40px] max-w-[40px] text-center", accessor: (_row, i) => i + 1 },
|
||||
{
|
||||
|
||||
@@ -33,7 +33,12 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
|
||||
// Prepare headers
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', 'application/json');
|
||||
const contentType = req.headers.get('content-type');
|
||||
if (contentType) {
|
||||
headers.set('Content-Type', contentType);
|
||||
} else {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
// Forward cookie for authentication
|
||||
const cookie = req.headers.get('cookie');
|
||||
@@ -51,18 +56,19 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
let body = null;
|
||||
|
||||
if (hasBody) {
|
||||
body = await req.text();
|
||||
body = await req.arrayBuffer();
|
||||
}
|
||||
|
||||
const response = await fetch(destinationUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: body || undefined,
|
||||
cache: 'no-store', // Prevent Next.js from aggressively caching the backend response
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
const responseBuffer = await response.arrayBuffer();
|
||||
|
||||
const nextResponse = new NextResponse(responseText, {
|
||||
const nextResponse = new NextResponse(responseBuffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, User, Key, Tag, Upload, Loader2, CheckCircle2, AlertCircle, Trash2, Shield } from "lucide-react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { updateAdminAgentUser, deleteAdminAgentUser, createAdminExternalUser } from "@/lib/admin-api";
|
||||
|
||||
interface ExternalAccountModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
user?: ManagedUser | null;
|
||||
}
|
||||
|
||||
export default function ExternalAccountModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
user,
|
||||
}: ExternalAccountModalProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [accountName, setAccountName] = useState("");
|
||||
const [role, setRole] = useState("SOC_ANALYST");
|
||||
const [previewPic, setPreviewPic] = useState<string | null>(null);
|
||||
const [picFile, setPicFile] = useState<File | null>(null);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setPicFile(null);
|
||||
if (user) {
|
||||
setUsername(user.username);
|
||||
setAccountName(user.account_name || "");
|
||||
setRole(user.role);
|
||||
setPassword(""); // Selalu kosong agar admin bisa memasukkan password baru untuk di-reset
|
||||
setPreviewPic(
|
||||
user.profile_picture
|
||||
? `/api/auth/uploads/${user.profile_picture}`
|
||||
: null
|
||||
);
|
||||
} else {
|
||||
setUsername("");
|
||||
setAccountName("");
|
||||
setRole("SOC_ANALYST");
|
||||
setPassword("");
|
||||
setPreviewPic(null);
|
||||
}
|
||||
}, [isOpen, user]);
|
||||
|
||||
const handlePicChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setPicFile(file);
|
||||
setPreviewPic(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isEdit = !!user;
|
||||
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (username.trim()) fd.append("username", username.trim());
|
||||
if (password) fd.append("password", password);
|
||||
fd.append("account_name", accountName.trim());
|
||||
if (picFile) fd.append("profile_picture", picFile);
|
||||
|
||||
if (isEdit) {
|
||||
fd.append("user_id", String(user.id));
|
||||
await updateAdminAgentUser(fd);
|
||||
setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!");
|
||||
} else {
|
||||
fd.append("role", role);
|
||||
await createAdminExternalUser(fd);
|
||||
setSuccess("External account successfully created!");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!user) return;
|
||||
if (!confirm(`Delete account "${user.username}"? This action cannot be undone.`)) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteAdminAgentUser(user.id);
|
||||
setSuccess("Account successfully deleted!");
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
}, 1000);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isEdit = !!user;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
className="relative z-10 w-full max-w-md bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-700 bg-gradient-to-r from-amber-900/40 to-slate-900">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
{isEdit ? "Manage External Account" : "Create External Account"}
|
||||
</h3>
|
||||
{isEdit && <p className="text-xs text-slate-400 mt-0.5 font-mono">Role: <span className="font-semibold text-amber-400">{user.role.replace('_', ' ')}</span></p>}
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-700 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{isEdit && (
|
||||
<div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg flex gap-3 text-xs text-amber-200/80 mb-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<p><strong>Password Recovery:</strong> For security reasons, original passwords cannot be viewed. To recover an account, enter a new password below to reset it.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Picture */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full border-2 border-dashed border-slate-600 flex items-center justify-center cursor-pointer hover:border-amber-500 transition-colors overflow-hidden bg-slate-800"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{previewPic ? (
|
||||
<img src={previewPic} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">Profile Picture</p>
|
||||
<p className="text-xs text-slate-500">Click to upload image</p>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePicChange} />
|
||||
</div>
|
||||
|
||||
{/* Account Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={e => setAccountName(e.target.value)}
|
||||
placeholder="Full Name"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role (Only for Create mode) */}
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Shield className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Role
|
||||
</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={e => setRole(e.target.value)}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
>
|
||||
<option value="SOC_ANALYST">SOC Analyst</option>
|
||||
<option value="ENGINEER">Engineer</option>
|
||||
<option value="TENANT_ADMIN">Tenant Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Login Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Username for login"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Reset / Create */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
{isEdit ? "Reset Password" : "Password"} {isEdit && <span className="text-slate-500">(leave blank to keep unchanged)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "Type new password to override" : "Create password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-green-900/30 border border-green-800 text-green-300 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isEdit && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -20,8 +20,11 @@ interface KPICardsProps {
|
||||
}
|
||||
|
||||
export function KPICards({ summary }: KPICardsProps) {
|
||||
const downloadSpeedBits = summary?.download_speed ? summary.download_speed * 8 : 0;
|
||||
const uploadSpeedBits = summary?.upload_speed ? summary.upload_speed * 8 : 0;
|
||||
// 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 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
@@ -38,7 +41,7 @@ export function KPICards({ summary }: KPICardsProps) {
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary ? fmtBytes(summary.bandwidth_down) : "0 B"}
|
||||
</div>
|
||||
{summary?.download_speed !== undefined && (
|
||||
{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
|
||||
@@ -65,7 +68,7 @@ export function KPICards({ summary }: KPICardsProps) {
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary ? fmtBytes(summary.bandwidth_up) : "0 B"}
|
||||
</div>
|
||||
{summary?.upload_speed !== undefined && (
|
||||
{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
|
||||
@@ -109,10 +112,10 @@ export function KPICards({ summary }: KPICardsProps) {
|
||||
<div className="text-3xl font-bold text-white tracking-tight">
|
||||
{summary?.active_flows?.toLocaleString() || "0"}
|
||||
</div>
|
||||
{summary?.flow_speed !== undefined && summary.flow_speed > 0 && (
|
||||
{summary && flowsPerHour > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1.5 flex items-center gap-1 group-hover:text-purple-400/80 transition-colors">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse" />
|
||||
Speed: {summary.flow_speed.toLocaleString()} /hr
|
||||
Speed: {flowsPerHour.toLocaleString()} /hr
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createPortal } from "react-dom";
|
||||
import { X, Loader2, Database, AlertCircle, Globe, Monitor, Cpu, Fingerprint, Hash, Terminal, Radio } from "lucide-react";
|
||||
import { useMetadataDetail, MetadataDetailType, MetadataDetailRow } from "@/lib/api-metadata-detail";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
|
||||
|
||||
// ─── Type config ───────────────────────────────────────────────────────────────
|
||||
interface TypeConfig {
|
||||
@@ -281,6 +281,11 @@ export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props)
|
||||
<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>
|
||||
@@ -315,7 +320,7 @@ export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props)
|
||||
{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 MongoDB...</p>
|
||||
<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">
|
||||
@@ -326,11 +331,11 @@ export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props)
|
||||
<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 MongoDB</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-[#111827] z-10">
|
||||
<thead className="sticky top-0 bg-slate-900 z-10">
|
||||
<TableHead cols={cols} />
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Sidebar } from "./Sidebar";
|
||||
import { getViewAsStatusSync, stopViewAs } from "@/lib/admin-api";
|
||||
import { AlertTriangle, LogOut, Clock, ShieldAlert, Loader2 } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { HelpCenterFAB } from "@/components/ui/HelpCenterFAB";
|
||||
|
||||
export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [viewAs, setViewAs] = useState<{ active: boolean; agent_label?: string; agent_uuid?: string }>({ active: false });
|
||||
@@ -160,6 +161,9 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Global Help Center Drawer */}
|
||||
<HelpCenterFAB />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
"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";
|
||||
@@ -34,9 +46,9 @@ export function Header() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Right Side: Empty for extreme minimalist look */}
|
||||
{/* Right Side */}
|
||||
<div className="flex items-center">
|
||||
{/* Ornamen lain bisa ditambahkan di masa depan jika diperlukan */}
|
||||
{/* Site switcher moved to Sidebar */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Upload, ImageIcon, Trash2 } from "lucide-react";
|
||||
import { SafeImage } from "../ui/SafeImage";
|
||||
|
||||
interface Props {
|
||||
user: any;
|
||||
@@ -91,7 +92,7 @@ export default function ProfilePictureTab({ user, setUser, onClose }: Props) {
|
||||
<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 ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
<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')}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Calendar } from "lucide-react";
|
||||
import { ChevronDown, Calendar, Activity } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AccountSettingsModal } from "./AccountSettingsModal";
|
||||
@@ -13,6 +13,7 @@ import { NAVIGATION_GROUPS, TIME_OPTIONS } from "./SidebarData";
|
||||
import SidebarProfile from "./SidebarProfile";
|
||||
import SidebarNotifications from "./SidebarNotifications";
|
||||
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { timeRange, setTimeRange } = useTimeFilter();
|
||||
@@ -25,6 +26,10 @@ export function Sidebar() {
|
||||
const timeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
const selectedTimeOption = TIME_OPTIONS.find(opt => opt.value === timeRange) || TIME_OPTIONS[3];
|
||||
|
||||
const [selectedSite, setSelectedSite] = useState<string>("6681452d_9cae_4ff4_8ae8_0d504774265e");
|
||||
const [isSiteDropdownOpen, setIsSiteDropdownOpen] = useState(false);
|
||||
const siteDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
@@ -50,16 +55,34 @@ export function Sidebar() {
|
||||
}
|
||||
})
|
||||
.catch(() => { });
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem('backone_site_uuid');
|
||||
if (stored) setSelectedSite(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (timeDropdownRef.current && !timeDropdownRef.current.contains(event.target as Node)) setIsTimeDropdownOpen(false);
|
||||
if (siteDropdownRef.current && !siteDropdownRef.current.contains(event.target as Node)) setIsSiteDropdownOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSiteSelect = (val: string) => {
|
||||
setSelectedSite(val);
|
||||
localStorage.setItem('backone_site_uuid', val);
|
||||
setIsSiteDropdownOpen(false);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const getSiteLabel = (uuid: string) => {
|
||||
if (uuid === 'd7902405_0dc2_458b_8584_ed4d24b64f24') return 'Nexus';
|
||||
return 'SIAB';
|
||||
};
|
||||
|
||||
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)]">
|
||||
{/* Brand & Site Info */}
|
||||
@@ -73,9 +96,66 @@ export function Sidebar() {
|
||||
<span className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold mt-1">
|
||||
Deep Package Inspection
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-2 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[8px] font-bold px-2 py-0.5 rounded-full w-fit">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
DPI ENGINE: ACTIVE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Site Switcher Widget (Visible to SUPER_ADMIN, SOC_ANALYST, ENGINEER) */}
|
||||
{user && ['SUPER_ADMIN', 'SOC_ANALYST', 'ENGINEER'].includes(user.role || '') && (
|
||||
<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('d7902405_0dc2_458b_8584_ed4d24b64f24')}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
selectedSite === 'd7902405_0dc2_458b_8584_ed4d24b64f24'
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
Nexus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Time Filter Widget */}
|
||||
<div className="relative w-full px-2 pb-2" ref={timeDropdownRef}>
|
||||
<button
|
||||
@@ -92,30 +172,36 @@ export function Sidebar() {
|
||||
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform duration-300", isTimeDropdownOpen && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
{isTimeDropdownOpen && (
|
||||
<div className="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">
|
||||
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{TIME_OPTIONS.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
|
||||
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">
|
||||
{TIME_OPTIONS.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>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Navigation (Flexible Scrollable Container for Rule 9) */}
|
||||
|
||||
@@ -13,35 +13,35 @@ export const NAVIGATION_GROUPS = [
|
||||
{
|
||||
title: "INFRASTRUCTURE",
|
||||
items: [
|
||||
{ name: "Devices", href: "/devices", icon: Router },
|
||||
{ name: "Agents", href: "/agents", icon: Server },
|
||||
{ name: "Topology", href: "/network-infrastructure", icon: Globe },
|
||||
{ name: "Agents", href: "/agents", icon: Server },
|
||||
{ name: "Devices", href: "/devices", icon: Router },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "MONITORING",
|
||||
title: "TRAFFIC & ANALYTICS",
|
||||
items: [
|
||||
{ name: "Flows", href: "/flows", icon: Activity },
|
||||
{ name: "Apps", href: "/apps", icon: AppWindow },
|
||||
{ name: "Traffic Categories", href: "/network-intelligence", icon: Activity },
|
||||
{ name: "DNS", href: "/dns", icon: Radio },
|
||||
{ name: "Events", href: "/events", icon: Zap },
|
||||
{ name: "Geo Traffic", href: "/geography", icon: Globe },
|
||||
{ name: "BackOne Metadata", href: "/dpi-analytics", icon: Activity },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "MANAGEMENT",
|
||||
title: "SECURITY & ALERTS",
|
||||
items: [
|
||||
{ name: "Threat Intelligence Feeds", href: "/intelligence", icon: Shield },
|
||||
{ name: "Threats", href: "/threats", icon: ShieldAlert },
|
||||
{ name: "Events", href: "/events", icon: Zap },
|
||||
{ name: "Encryption Audit (TLS)", href: "/security-audit", icon: Lock },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "TOOLS & MANAGEMENT",
|
||||
items: [
|
||||
{ name: "Lookup", href: "/lookup", icon: Search },
|
||||
{ name: "Threats", href: "/threats", icon: ShieldAlert },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "SYSTEM",
|
||||
items: [
|
||||
{ name: "Geo Traffic", href: "/geography", icon: Globe },
|
||||
{ name: "Traffic Categories", href: "/network-intelligence", icon: Activity },
|
||||
{ name: "Encryption Audit (TLS)", href: "/security-audit", icon: Lock },
|
||||
{ name: "Threat Intelligence Feeds", href: "/intelligence", icon: Shield },
|
||||
{ name: "BackOne Metadata", href: "/dpi-analytics", icon: Activity },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState, useRef, useEffect } from "react";
|
||||
import { User as UserIcon, Sun, Moon, LogOut } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { SafeImage } from "../ui/SafeImage";
|
||||
|
||||
const ROLE_DISPLAY_NAMES: Record<string, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
@@ -51,7 +52,7 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
>
|
||||
<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 ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
<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}
|
||||
@@ -69,7 +70,7 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
<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 ? (
|
||||
<img src={`/api/uploads/${user.profile_picture}`} alt="Profile" className="w-full h-full object-cover" />
|
||||
<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}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"use client";
|
||||
|
||||
// src/components/network/CategoryDetailPanel.tsx
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Detail modal for Traffic Categories.
|
||||
// Displays Top Applications and Top Devices for a selected category.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Loader2, Database, LayoutGrid, Monitor, LayoutList, AlertCircle } from "lucide-react";
|
||||
import { useCategoryDetail, CategoryDetailAppRow, CategoryDetailDeviceRow } from "@/lib/api-category-detail";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
// ─── Cell with overflow ellipsis + title tooltip ────────────────────────────────
|
||||
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 CategoryDetailPanel({
|
||||
category,
|
||||
onClose,
|
||||
timeRange = "1d"
|
||||
}: {
|
||||
category: string;
|
||||
onClose: () => void;
|
||||
timeRange?: string;
|
||||
}) {
|
||||
const { data, isLoading, error } = useCategoryDetail(category, timeRange);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"apps" | "devices">("apps");
|
||||
const [page, setPage] = useState(1);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(onClose, 300);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") handleClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleEsc);
|
||||
return () => window.removeEventListener("keydown", handleEsc);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => { document.body.style.overflow = "unset"; };
|
||||
}, []);
|
||||
|
||||
// Reset page when tab changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [activeTab]);
|
||||
|
||||
const currentData = activeTab === "apps" ? data.apps : data.devices;
|
||||
const totalPages = Math.max(1, Math.ceil(currentData.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const pageStart = (safePage - 1) * PAGE_SIZE;
|
||||
const pagedData = currentData.slice(pageStart, pageStart + PAGE_SIZE);
|
||||
|
||||
const totalDownload = currentData.reduce((s, r) => s + (r.download || 0), 0);
|
||||
const totalUpload = currentData.reduce((s, r) => s + (r.upload || 0), 0);
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4" onClick={handleClose}>
|
||||
{/* Backdrop */}
|
||||
<div className={`absolute inset-0 bg-black/60 backdrop-blur-md ${isClosing ? "animate-overlay-out" : "animate-overlay-in"}`} />
|
||||
|
||||
{/* Dialog */}
|
||||
<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()}
|
||||
>
|
||||
{/* Top accent line */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
|
||||
|
||||
{/* ── Header ── */}
|
||||
<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 bg-blue-500 blur-xl opacity-20 rounded-full" />
|
||||
<div className="p-2.5 bg-white/[0.06] rounded-xl ring-1 ring-blue-500/30 relative">
|
||||
<LayoutGrid className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest mb-0.5 text-blue-400">Traffic Category</p>
|
||||
<h3 className="text-lg font-bold text-card-foreground truncate" title={category}>{category}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Top applications and devices</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>
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<div className="flex border-b border-border bg-card/50 flex-shrink-0 px-4 pt-4">
|
||||
<button
|
||||
onClick={() => setActiveTab("apps")}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "apps"
|
||||
? "border-blue-500 text-blue-400"
|
||||
: "border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700"
|
||||
}`}
|
||||
>
|
||||
<LayoutList className="w-4 h-4" />
|
||||
Top Applications
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("devices")}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "devices"
|
||||
? "border-blue-500 text-blue-400"
|
||||
: "border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700"
|
||||
}`}
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
Top Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Summary bar ── */}
|
||||
{!isLoading && currentData.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">{currentData.length.toLocaleString()}</span>
|
||||
{" "}{currentData.length === 1 ? "record" : "records"}
|
||||
</span>
|
||||
<div className="h-3 w-px bg-white/10" />
|
||||
<span className="text-muted-foreground">
|
||||
Download: <span className="font-mono text-cyan-400">{fmtBytes(totalDownload)}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Upload: <span className="font-mono text-emerald-400">{fmtBytes(totalUpload)}</span>
|
||||
</span>
|
||||
<div className="h-3 w-px bg-white/10" />
|
||||
<span className="text-slate-500 text-[10px] uppercase tracking-wider">
|
||||
Sorted by bandwidth
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Content Area ── */}
|
||||
<div className="flex-1 overflow-auto bg-black/20">
|
||||
{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>
|
||||
) : currentData.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-secondary z-10 shadow-md">
|
||||
{activeTab === "apps" ? (
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center w-[33%]">Application</th>
|
||||
<th className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center w-[33%]">Download</th>
|
||||
<th className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center w-[33%]">Upload</th>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center w-[16%]">Source IP</th>
|
||||
<th className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center w-[16%]">MAC Address</th>
|
||||
<th className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center w-[20%]">Device Name</th>
|
||||
<th className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center w-[16%]">Manufacturer</th>
|
||||
<th className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center w-[16%]">Download</th>
|
||||
<th className="px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center w-[16%]">Upload</th>
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{activeTab === "apps" ? (
|
||||
(pagedData as CategoryDetailAppRow[]).map((row, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5 text-sm text-center align-middle"><Cell value={row.app_label || "—"} className="text-slate-200" /></td>
|
||||
<td className="px-3 py-2.5 text-sm text-center align-middle"><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className="px-3 py-2.5 text-sm text-center align-middle"><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
(pagedData as CategoryDetailDeviceRow[]).map((row, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5 text-sm text-center align-middle"><Cell value={row.src_ip || "—"} className="text-blue-400 font-mono text-xs" /></td>
|
||||
<td className="px-3 py-2.5 text-sm text-center align-middle"><Cell value={row.mac_address || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className="px-3 py-2.5 text-sm text-center align-middle"><Cell value={row.device_label || row.src_ip || "—"} className="text-slate-200" /></td>
|
||||
<td className="px-3 py-2.5 text-sm text-center align-middle"><Cell value={row.manufacturer || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className="px-3 py-2.5 text-sm text-center align-middle"><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className="px-3 py-2.5 text-sm text-center align-middle"><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer Pagination ── */}
|
||||
{!isLoading && totalPages > 1 && (
|
||||
<div className="border-t border-border bg-card p-4 flex justify-end flex-shrink-0">
|
||||
<Pagination
|
||||
currentPage={safePage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={currentData.length}
|
||||
startIndex={(safePage - 1) * PAGE_SIZE}
|
||||
endIndex={Math.min(safePage * PAGE_SIZE, currentData.length)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { ShieldCheck, ShieldAlert, Loader2, Info, ChevronDown } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
|
||||
const RISK_CONFIG: Record<string, any> = {
|
||||
Vulnerable: { color: "text-red-400", bg: "bg-red-500/20 ring-red-500/40", icon: <ShieldAlert className="w-3.5 h-3.5" />, dot: "bg-red-500" },
|
||||
@@ -74,6 +75,11 @@ export default function DeviceRiskTable({ secDevices, secDevicesLoading }: Props
|
||||
const [filterRisk, setFilterRisk] = useState<string>("All");
|
||||
|
||||
const secDeviceColumns: Column<SecurityDevice>[] = [
|
||||
{
|
||||
header: "Timestamp",
|
||||
className: "w-[150px] min-w-[150px] max-w-[150px] text-center",
|
||||
accessor: (row) => <TimestampCell timestamp={row.timestamp} showActiveStatus={false} />
|
||||
},
|
||||
{ header: "#", accessor: (_, i) => i + 1, className: "w-[60px] min-w-[60px] max-w-[60px]" },
|
||||
{
|
||||
header: "IP Address",
|
||||
|
||||
@@ -49,19 +49,12 @@ export default function SecurityDistribution({ securityData }: Props) {
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col justify-center ml-4 space-y-3">
|
||||
{securityData.map((entry, index) => {
|
||||
const TLS_VER: Record<string, string> = {
|
||||
Recommended: "TLS 1.3",
|
||||
Secure: "TLS 1.2",
|
||||
Weak: "TLS 1.0 / 1.1",
|
||||
Insecure: "SSL 2.0 / 3.0",
|
||||
};
|
||||
const dotColor = COLORS[entry.tls_security as keyof typeof COLORS] || "#8884d8";
|
||||
return (
|
||||
<div key={`${entry.tls_security}-${index}`} className="flex items-start gap-2 whitespace-nowrap">
|
||||
<div className="w-3 h-3 rounded-full shrink-0 mt-0.5" style={{ backgroundColor: dotColor }} />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col justify-center h-4">
|
||||
<span className="text-xs text-white font-medium">{entry.tls_security}</span>
|
||||
<span className="text-[10px] font-mono" style={{ color: dotColor }}>{TLS_VER[entry.tls_security] || ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,11 +6,17 @@ import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Loader2, Info } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
|
||||
export default function SslSanTable() {
|
||||
const { data, isLoading } = useSslSans(100);
|
||||
|
||||
const columns: Column<SslSanStat>[] = [
|
||||
{
|
||||
header: "Timestamp",
|
||||
className: "w-[150px] min-w-[150px] max-w-[150px] text-center",
|
||||
accessor: (row) => <TimestampCell timestamp={row.timestamp} showActiveStatus={false} />
|
||||
},
|
||||
{
|
||||
header: "#",
|
||||
accessor: (_, i) => i + 1,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TlsCipherStat } from "@/lib/api";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { X, ShieldAlert, ShieldCheck, Shield, Lock, Activity, Download, Upload } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
cipher: TlsCipherStat;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TlsCipherDetailModal({ cipher, onClose }: Props) {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => { onClose(); }, 480);
|
||||
};
|
||||
|
||||
const status = cipher.security_status || 'Unknown';
|
||||
|
||||
let Icon = Shield;
|
||||
let bgGradient = "from-slate-900/50 to-slate-800/30";
|
||||
let textColor = "text-slate-400";
|
||||
let badgeColor = "bg-slate-500/10 text-slate-400 border-slate-500/20";
|
||||
let recommendation = "No specific recommendation available.";
|
||||
|
||||
if (status === 'Secure') {
|
||||
Icon = ShieldCheck;
|
||||
bgGradient = "from-emerald-900/30 to-green-900/20";
|
||||
textColor = "text-emerald-400";
|
||||
badgeColor = "bg-emerald-500/10 text-emerald-400 border-emerald-500/20";
|
||||
recommendation = "This cipher suite provides strong modern encryption. It is recommended to keep this enabled for secure communications.";
|
||||
} else if (status === 'Weak') {
|
||||
Icon = ShieldAlert;
|
||||
bgGradient = "from-amber-900/30 to-orange-900/20";
|
||||
textColor = "text-amber-400";
|
||||
badgeColor = "bg-amber-500/10 text-amber-400 border-amber-500/20";
|
||||
recommendation = "This cipher suite uses legacy algorithms that may be vulnerable to theoretical attacks. Consider deprecating its use if compatibility allows.";
|
||||
} else if (status === 'Vulnerable') {
|
||||
Icon = ShieldAlert;
|
||||
bgGradient = "from-red-900/30 to-rose-900/20";
|
||||
textColor = "text-red-400";
|
||||
badgeColor = "bg-red-500/10 text-red-400 border-red-500/20";
|
||||
recommendation = "CRITICAL: This cipher suite uses obsolete and highly insecure algorithms (e.g., NULL, RC4, DES, MD5). It must be disabled immediately across all infrastructure to prevent data breaches.";
|
||||
}
|
||||
|
||||
// Parse cipher suite parts roughly
|
||||
const parts = cipher.tls_cipher.split('_');
|
||||
const isTls = parts[0] === 'TLS';
|
||||
const keyExchange = isTls && parts.length > 2 ? parts[1] : 'Unknown';
|
||||
let encryption = 'Unknown';
|
||||
let mac = 'Unknown';
|
||||
|
||||
if (cipher.tls_cipher.includes('WITH')) {
|
||||
const withSplit = cipher.tls_cipher.split('_WITH_');
|
||||
if (withSplit.length > 1) {
|
||||
const rest = withSplit[1].split('_');
|
||||
if (rest.length > 2) {
|
||||
mac = rest.pop() || 'Unknown';
|
||||
encryption = rest.join('_');
|
||||
} else {
|
||||
encryption = rest[0];
|
||||
mac = rest[1] || 'Unknown';
|
||||
}
|
||||
}
|
||||
} else if (isTls) {
|
||||
if (parts.length >= 5) { // e.g. TLS_AES_256_GCM_SHA384
|
||||
encryption = parts[1] + '_' + parts[2] + '_' + parts[3];
|
||||
mac = parts[4];
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed top-0 left-0 w-screen h-[100dvh] z-[100] flex items-center justify-center p-4 overflow-y-auto" onClick={handleClose}>
|
||||
<div className={"fixed top-0 left-0 w-screen h-[100dvh] bg-black/40 dark:bg-black/75 backdrop-blur-sm " + (isClosing ? 'animate-overlay-out' : 'animate-overlay-in')} />
|
||||
|
||||
<div
|
||||
className={"relative bg-card border border-border rounded-2xl w-full max-w-2xl flex flex-col shadow-2xl shadow-black/60 my-auto " + (isClosing ? 'animate-modal-out' : 'animate-modal-in')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className={"flex items-center justify-between px-6 py-5 border-b border-border bg-gradient-to-r rounded-t-2xl " + bgGradient}>
|
||||
<div className="flex flex-col gap-1.5 min-w-0 pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-black/20 ring-1 ring-white/10">
|
||||
<Icon className={"w-5 h-5 " + textColor} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white truncate max-w-[400px] font-mono tracking-tight" title={cipher.tls_cipher}>
|
||||
{cipher.tls_cipher}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={"px-2 py-0.5 text-xs font-medium rounded-full border uppercase tracking-wider " + badgeColor}>
|
||||
{status}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">Cipher Intelligence</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 -mr-2 text-slate-400 hover:text-white hover:bg-white/10 rounded-full transition-colors flex-shrink-0 focus:outline-none"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar bg-black/20">
|
||||
|
||||
{/* Description Card */}
|
||||
<div className="bg-secondary/20 border border-border/50 rounded-xl p-4">
|
||||
<h4 className="text-sm font-semibold text-white flex items-center gap-2 mb-2">
|
||||
<Shield className="w-4 h-4 text-purple-400" />
|
||||
Security Assessment
|
||||
</h4>
|
||||
<p className="text-sm text-slate-300 leading-relaxed">
|
||||
{cipher.description || 'No detailed assessment available.'}
|
||||
</p>
|
||||
<div className="mt-3 p-3 bg-black/30 rounded-lg border border-white/5">
|
||||
<h5 className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">Recommendation</h5>
|
||||
<p className={"text-sm " + textColor}>{recommendation}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Algorithm Details */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="bg-secondary/20 border border-border/50 rounded-xl p-4 flex flex-col justify-center items-center text-center">
|
||||
<Lock className="w-5 h-5 text-blue-400 mb-2" />
|
||||
<span className="text-[10px] text-slate-400 uppercase tracking-wider">Key Exchange</span>
|
||||
<span className="text-sm font-semibold text-white mt-1 font-mono">{keyExchange}</span>
|
||||
</div>
|
||||
<div className="bg-secondary/20 border border-border/50 rounded-xl p-4 flex flex-col justify-center items-center text-center">
|
||||
<Shield className="w-5 h-5 text-amber-400 mb-2" />
|
||||
<span className="text-[10px] text-slate-400 uppercase tracking-wider">Encryption</span>
|
||||
<span className="text-sm font-semibold text-white mt-1 font-mono">{encryption}</span>
|
||||
</div>
|
||||
<div className="bg-secondary/20 border border-border/50 rounded-xl p-4 flex flex-col justify-center items-center text-center">
|
||||
<Activity className="w-5 h-5 text-emerald-400 mb-2" />
|
||||
<span className="text-[10px] text-slate-400 uppercase tracking-wider">Hashing (Auth)</span>
|
||||
<span className="text-sm font-semibold text-white mt-1 font-mono">{mac}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic Volume */}
|
||||
<div className="bg-secondary/20 border border-border/50 rounded-xl p-4">
|
||||
<h4 className="text-sm font-semibold text-white mb-4">Network Traffic Volume</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-400 flex items-center gap-1.5"><Download className="w-3.5 h-3.5 text-blue-400"/> Download</span>
|
||||
<span className="text-base font-semibold text-blue-400">{fmtBytes(cipher.download)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-l border-white/10 pl-4">
|
||||
<span className="text-xs text-slate-400 flex items-center gap-1.5"><Upload className="w-3.5 h-3.5 text-green-400"/> Upload</span>
|
||||
<span className="text-base font-semibold text-green-400">{fmtBytes(cipher.upload)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-l border-white/10 pl-4">
|
||||
<span className="text-xs text-slate-400 flex items-center gap-1.5"><Activity className="w-3.5 h-3.5 text-purple-400"/> Total</span>
|
||||
<span className="text-base font-semibold text-white">{fmtBytes(cipher.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Devices Limitation Note */}
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-blue-300/80 italic">
|
||||
Device-level mapping for cipher suites is currently not collected by the proxy layer.
|
||||
Data shown above reflects aggregated network-wide telemetry.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
// src/components/security-audit/TlsTables.tsx
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TlsVersionStat, TlsCipherStat } from "@/lib/api";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { TlsCipherDetailModal } from "./TlsCipherDetailModal";
|
||||
import { Shield, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
versionsData: TlsVersionStat[];
|
||||
@@ -12,8 +15,15 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function TlsTables({ versionsData, ciphersData }: Props) {
|
||||
const [selectedCipher, setSelectedCipher] = useState<TlsCipherStat | null>(null);
|
||||
|
||||
const versionColumns: Column<TlsVersionStat>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[40px] min-w-[40px] max-w-[40px]" },
|
||||
{
|
||||
header: "Timestamp",
|
||||
className: "w-[120px] min-w-[120px] text-center",
|
||||
accessor: (row) => <TimestampCell timestamp={row.timestamp} showActiveStatus={false} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[40px] min-w-[40px]" },
|
||||
{
|
||||
header: "TLS Version",
|
||||
accessor: (row) => (
|
||||
@@ -21,62 +31,101 @@ export default function TlsTables({ versionsData, ciphersData }: Props) {
|
||||
{row.tls_version || '-'}
|
||||
</div>
|
||||
),
|
||||
className: "w-[120px] min-w-[120px] max-w-[120px]"
|
||||
className: "w-[120px] min-w-[120px]"
|
||||
},
|
||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>, className: "w-[90px] min-w-[90px] max-w-[90px]" },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>, className: "w-[90px] min-w-[90px] max-w-[90px]" },
|
||||
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span>, className: "w-[110px] min-w-[110px] max-w-[110px]" },
|
||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>, className: "w-[80px] min-w-[80px]" },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>, className: "w-[80px] min-w-[80px]" },
|
||||
{ header: "Total", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span>, className: "w-[80px] min-w-[80px]" },
|
||||
];
|
||||
|
||||
const cipherColumns: Column<TlsCipherStat>[] = [
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[40px] min-w-[40px] max-w-[40px]" },
|
||||
{
|
||||
header: "Timestamp",
|
||||
className: "w-[120px] min-w-[120px] text-center",
|
||||
accessor: (row) => <TimestampCell timestamp={row.timestamp} showActiveStatus={false} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[40px] min-w-[40px]" },
|
||||
{
|
||||
header: "Cipher Suite",
|
||||
accessor: (row) => (
|
||||
<div title={row.tls_cipher || '-'} className="text-purple-400 truncate w-full block text-center cursor-help">
|
||||
{row.tls_cipher || '-'}
|
||||
</div>
|
||||
),
|
||||
className: "w-[200px] min-w-[200px] max-w-[200px]"
|
||||
accessor: (row) => {
|
||||
const s = row.security_status || 'Unknown';
|
||||
let Icon = Shield;
|
||||
let color = "text-slate-400";
|
||||
if (s === 'Secure') { Icon = ShieldCheck; color = "text-emerald-400"; }
|
||||
else if (s === 'Weak') { Icon = ShieldAlert; color = "text-amber-400"; }
|
||||
else if (s === 'Vulnerable') { Icon = ShieldAlert; color = "text-red-400"; }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1 w-full">
|
||||
<button
|
||||
onClick={() => setSelectedCipher(row)}
|
||||
className="text-purple-400 hover:text-purple-300 transition-colors font-mono truncate max-w-[200px] sm:max-w-[200px] md:max-w-[350px] block"
|
||||
title={row.tls_cipher || '-'}
|
||||
>
|
||||
{row.tls_cipher || '-'}
|
||||
</button>
|
||||
{s !== 'Unknown' && (
|
||||
<div className={"flex items-center gap-1 text-[10px] uppercase font-semibold " + color} title={row.description}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{s}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: "w-[30%] min-w-[200px]"
|
||||
},
|
||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>, className: "w-[90px] min-w-[90px] max-w-[90px]" },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>, className: "w-[90px] min-w-[90px] max-w-[90px]" },
|
||||
{ header: "Total Volume", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span>, className: "w-[110px] min-w-[110px] max-w-[110px]" },
|
||||
{ header: "Download", accessor: (row) => <span className="text-blue-400">{fmtBytes(row.download)}</span>, className: "w-[80px] min-w-[80px]" },
|
||||
{ header: "Upload", accessor: (row) => <span className="text-green-500">{fmtBytes(row.upload)}</span>, className: "w-[80px] min-w-[80px]" },
|
||||
{ header: "Total", accessor: (row) => <span className="font-medium text-white">{fmtBytes(row.total)}</span>, className: "w-[80px] min-w-[80px]" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* TLS Versions Table */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">TLS Versions Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={versionsData}
|
||||
columns={versionColumns}
|
||||
searchPlaceholder="Search versions..."
|
||||
searchFilter={(row, query) => (row.tls_version || "").toLowerCase().includes(query.toLowerCase())}
|
||||
pageSize={50}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<>
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{/* TLS Versions Table */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50 max-w-full overflow-hidden flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">TLS Versions Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-w-0 p-2 sm:p-6 overflow-hidden">
|
||||
<div className="overflow-x-auto w-full custom-scrollbar">
|
||||
<DataTable
|
||||
data={versionsData}
|
||||
columns={versionColumns}
|
||||
searchPlaceholder="Search versions..."
|
||||
searchFilter={(row, query) => (row.tls_version || "").toLowerCase().includes(query.toLowerCase())}
|
||||
pageSize={50}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TLS Ciphers Table */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Top TLS Cipher Suites</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={ciphersData}
|
||||
columns={cipherColumns}
|
||||
searchPlaceholder="Search ciphers..."
|
||||
searchFilter={(row, query) => (row.tls_cipher || "").toLowerCase().includes(query.toLowerCase())}
|
||||
pageSize={50}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* TLS Ciphers Table */}
|
||||
<Card className="bg-card/50 backdrop-blur-sm border-border/50 max-w-full overflow-hidden flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Top TLS Cipher Suites</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-w-0 p-2 sm:p-6 overflow-hidden">
|
||||
<div className="overflow-x-auto w-full custom-scrollbar">
|
||||
<DataTable
|
||||
data={ciphersData}
|
||||
columns={cipherColumns}
|
||||
searchPlaceholder="Search ciphers..."
|
||||
searchFilter={(row, query) => (row.tls_cipher || "").toLowerCase().includes(query.toLowerCase())}
|
||||
pageSize={50}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{selectedCipher && (
|
||||
<TlsCipherDetailModal
|
||||
cipher={selectedCipher}
|
||||
onClose={() => setSelectedCipher(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { ThreatStat } from "@/lib/api";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
import { DropdownFilterSelect } from "./DropdownFilterSelect";
|
||||
import { DateRangePicker } from "./DateRangePicker";
|
||||
import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect";
|
||||
import { DateRangePicker } from "@/components/ui/DateRangePicker";
|
||||
import { sortAppOptions } from "./sortAppOptions";
|
||||
|
||||
interface ThreatFiltersProps {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { Monitor, ChevronRight } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
interface Props {
|
||||
devices: any[];
|
||||
@@ -26,12 +27,68 @@ export default function AgentDevicesTab({ devices, onSelectDevice }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(devices.length / pageSize);
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(devices.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "device_type", label: "Device Type", type: "select", value: filters.device_type, options: opts("device_type") },
|
||||
{ id: "os_label", label: "OS", type: "select", value: filters.os_label, options: opts("os_label") },
|
||||
{ id: "manufacturer", label: "Manufacturer", type: "select", value: filters.manufacturer, options: opts("manufacturer") },
|
||||
{ id: "risk_level", label: "Risk", type: "select", value: filters.risk_level, options: opts("risk_level") },
|
||||
{ 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 = devices.filter((row: any) => {
|
||||
if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false;
|
||||
if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false;
|
||||
if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false;
|
||||
if (filters.risk_level && filters.risk_level !== "All" && row.risk_level !== filters.risk_level) 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 totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = devices.slice(startIndex, startIndex + pageSize);
|
||||
const paginated = processedData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{paginated.map((d, i) => {
|
||||
const rc = riskStyle(d.risk_level);
|
||||
@@ -59,8 +116,13 @@ export default function AgentDevicesTab({ devices, onSelectDevice }: Props) {
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||
<p className="text-xs text-muted-foreground font-mono">{d.ip_address}</p>
|
||||
{d.mac_address && <p className="text-xs text-muted-foreground font-mono">{d.mac_address}</p>}
|
||||
{d.device_type && (
|
||||
<p className="text-xs font-semibold text-slate-300">
|
||||
{d.device_type}
|
||||
</p>
|
||||
)}
|
||||
{d.os_label && <p className="text-xs text-blue-400">{d.os_label}</p>}
|
||||
{d.mac_address && <p className="text-xs text-muted-foreground font-mono">{d.mac_address}</p>}
|
||||
{d.manufacturer && <p className="text-xs text-muted-foreground">{d.manufacturer}</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,14 +140,16 @@ export default function AgentDevicesTab({ devices, onSelectDevice }: Props) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={devices.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { IpDetails } from "./IpDetails";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
interface ColDef {
|
||||
header: string;
|
||||
@@ -14,15 +15,13 @@ interface ColDef {
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[800px]">
|
||||
<table className="w-full text-xs text-center table-fixed whitespace-nowrap min-w-[800px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] text-center ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
@@ -38,11 +37,11 @@ function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef
|
||||
}
|
||||
|
||||
// ─── Cell with overflow ellipsis + title tooltip ────────────────────────────────
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
@@ -56,62 +55,118 @@ export default function AgentFlowsTab({ flows }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(flows.length / pageSize);
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(flows.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_label", label: "App", type: "select", value: filters.app_label, options: opts("app_label"), formatter: formatAppLabel },
|
||||
{ id: "protocol", label: "Protocol", type: "select", value: filters.protocol, options: opts("protocol"), formatter: formatAppLabel },
|
||||
{ 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 = flows.filter((row: any) => {
|
||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_label) return false;
|
||||
if (filters.protocol && filters.protocol !== "All" && row.protocol !== filters.protocol) 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 totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = flows.slice(startIndex, startIndex + pageSize);
|
||||
const paginated = processedData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "Source IP", width: "min-w-[100px]" },
|
||||
{ header: "Destination", width: "min-w-[140px]" },
|
||||
{ header: "App / Domain", width: "min-w-[160px] w-full" },
|
||||
{ header: "Protocol", width: "w-[80px]" },
|
||||
{ header: "Download", width: "w-[90px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[90px]", align: "right" },
|
||||
{ header: "Last Seen", width: "w-[120px]" },
|
||||
{ header: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Source IP", width: "w-[15%]", align: "center" },
|
||||
{ header: "Destination", width: "w-[15%]", align: "center" },
|
||||
{ header: "App / Domain", width: "w-[20%]", align: "center" },
|
||||
{ header: "Protocol", width: "w-[10%]", align: "center" },
|
||||
{ header: "Download", width: "w-[10%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[10%]", align: "center" },
|
||||
{ header: "Last Seen", width: "w-[15%]", align: "center" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<TableWrap cols={COLS}>
|
||||
{paginated.map((f, i) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={f.src_ip || "—"} className="font-mono text-blue-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={startIndex + i + 1} center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.src_ip || "—"} className="font-mono text-blue-400" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 truncate min-w-0">
|
||||
<div className="font-mono text-slate-300 truncate" title={`${f.dst_ip || "—"}${f.dst_port ? ':' + f.dst_port : ''}`}>
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 truncate w-full">
|
||||
<div className="font-mono text-slate-300 truncate text-center" title={`${f.dst_ip || "—"}${f.dst_port ? ':' + f.dst_port : ''}`}>
|
||||
{f.dst_ip || "—"}{f.dst_port ? <span className="text-muted-foreground">:{f.dst_port}</span> : ""}
|
||||
</div>
|
||||
{f.dst_ip && <IpDetails ip={f.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 truncate min-w-0">
|
||||
<div className="font-semibold text-purple-300 truncate" title={f.app_label || "—"}>
|
||||
{f.app_label || "—"}
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 truncate w-full">
|
||||
<div className="font-semibold text-purple-300 truncate text-center" title={formatAppLabel(f.app_label) || "—"}>
|
||||
{formatAppLabel(f.app_label) || "—"}
|
||||
</div>
|
||||
{f.domain && (
|
||||
<div className="text-[10px] text-muted-foreground italic truncate" title={f.domain}>
|
||||
<div className="text-[10px] text-muted-foreground italic truncate text-center" title={f.domain}>
|
||||
{f.domain}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.protocol || "—"} className="text-muted-foreground uppercase text-[10px]" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.last_seen ? f.last_seen.slice(0, 16).replace("T", " ") : "—"} className="text-slate-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.protocol || "—"} className="text-muted-foreground uppercase text-[10px]" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={f.last_seen ? f.last_seen.slice(0, 16).replace("T", " ") : "—"} className="text-slate-400" center /></td>
|
||||
</tr>
|
||||
))}
|
||||
</TableWrap>
|
||||
{totalPages > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={flows.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<div className="pt-2">
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function AgentMacTab({ macBandwidth }: Props) {
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{paginated.map((m, i) => {
|
||||
const maxTotal = Math.max(...macBandwidth.map(x => x.total ?? 0), 1);
|
||||
const maxTotal = macBandwidth.reduce((max: number, x: any) => Math.max(max, x.total ?? 0), 1);
|
||||
const pct = ((m.total ?? 0) / maxTotal) * 100;
|
||||
return (
|
||||
<div key={i} className="p-3 bg-white/[0.03] hover:bg-white/[0.06] border border-border/50 rounded-xl transition-colors">
|
||||
|
||||
@@ -17,15 +17,13 @@ interface ColDef {
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[700px]">
|
||||
<table className="w-full text-xs text-center table-fixed whitespace-nowrap min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] text-center ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
@@ -40,11 +38,11 @@ function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
@@ -69,22 +67,24 @@ export default function AgentSecurityTab({ data }: Props) {
|
||||
if (!security) return <EmptyState msg="No security data available." />;
|
||||
|
||||
const INSECURE_COLS: ColDef[] = [
|
||||
{ header: "IP", width: "min-w-[100px]" },
|
||||
{ header: "Protocol", width: "min-w-[80px]" },
|
||||
{ header: "Risk", width: "min-w-[100px]" },
|
||||
{ header: "App", width: "min-w-[120px] w-full" },
|
||||
{ header: "Dst IP:Port", width: "min-w-[140px]" },
|
||||
{ header: "Download", width: "w-[80px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[80px]", align: "right" },
|
||||
{ header: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "IP", width: "w-[15%]", align: "center" },
|
||||
{ header: "Protocol", width: "w-[10%]", align: "center" },
|
||||
{ header: "Risk", width: "w-[15%]", align: "center" },
|
||||
{ header: "App", width: "w-[20%]", align: "center" },
|
||||
{ header: "Dst IP:Port", width: "w-[15%]", align: "center" },
|
||||
{ header: "Download", width: "w-[10%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[10%]", align: "center" },
|
||||
];
|
||||
|
||||
const REPUTATION_COLS: ColDef[] = [
|
||||
{ header: "Remote IP", width: "min-w-[120px]" },
|
||||
{ header: "Local IP", width: "min-w-[120px]" },
|
||||
{ header: "Reputation", width: "min-w-[140px] w-full" },
|
||||
{ header: "Score", width: "w-[80px]", align: "center" },
|
||||
{ header: "Country", width: "w-[80px]" },
|
||||
{ header: "Status", width: "w-[100px]" },
|
||||
{ header: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Remote IP", width: "w-[20%]", align: "center" },
|
||||
{ header: "Local IP", width: "w-[20%]", align: "center" },
|
||||
{ header: "Reputation", width: "w-[25%]", align: "center" },
|
||||
{ header: "Score", width: "w-[10%]", align: "center" },
|
||||
{ header: "Country", width: "w-[10%]", align: "center" },
|
||||
{ header: "Status", width: "w-[10%]", align: "center" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -101,25 +101,26 @@ export default function AgentSecurityTab({ data }: Props) {
|
||||
const sc = severityStyle(r.risk);
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-blue-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.protocol || "—"} className="text-slate-200" /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<td className="px-3 py-2.5"><Cell value={i + 1} center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-blue-400" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.protocol || "—"} className="text-slate-200" center /></td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] uppercase font-semibold tracking-wide ring-1 ${sc.badge}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${sc.dot}`} />
|
||||
{r.risk || "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.app_label || "—"} className="text-purple-300" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.app_label || "—"} className="text-purple-300" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex flex-col gap-0.5 truncate min-w-0">
|
||||
<div className="font-mono text-muted-foreground truncate" title={r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}>
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 truncate w-full">
|
||||
<div className="font-mono text-muted-foreground truncate text-center" title={r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}>
|
||||
{r.dst_ip ? `${r.dst_ip}:${r.dst_port ?? "?"}` : "—"}
|
||||
</div>
|
||||
{r.dst_ip && <IpDetails ip={r.dst_ip} />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(r.download)} className="text-cyan-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(r.upload)} className="text-green-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(r.download)} className="text-cyan-400 font-mono" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(r.upload)} className="text-green-400 font-mono" center /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -137,13 +138,14 @@ export default function AgentSecurityTab({ data }: Props) {
|
||||
<TableWrap cols={REPUTATION_COLS}>
|
||||
{security.ip_reputation.map((r: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-red-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.local_ip || "—"} className="font-mono text-blue-400" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.reputation || "—"} className="text-slate-300" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={i + 1} center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.ip_address || "—"} className="font-mono text-red-400" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.local_ip || "—"} className="font-mono text-blue-400" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.reputation || "—"} className="text-slate-300" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.score ?? "—"} className="text-yellow-400 font-mono" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={r.country || "—"} className="text-muted-foreground" /></td>
|
||||
<td className="px-3 py-2.5 text-xs">
|
||||
{r.blacklisted ? <span className="text-red-400 font-semibold">Blocked</span> : <span className="text-green-400">Allowed</span>}
|
||||
<td className="px-3 py-2.5"><Cell value={r.country || "—"} className="text-muted-foreground" center /></td>
|
||||
<td className="px-3 py-2.5 text-center text-xs">
|
||||
{r.blacklisted ? <span className="text-red-400 font-semibold">Blocked</span> : <span className="text-green-400 font-semibold">Allowed</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -18,15 +18,13 @@ interface ColDef {
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-hidden rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[700px]">
|
||||
<table className="w-full text-xs text-center table-fixed whitespace-nowrap min-w-[700px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] text-center ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
@@ -41,11 +39,11 @@ function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
@@ -155,38 +153,36 @@ export function AppDetailModal({ appLabel, favicon, category, agentUuid, onClose
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<TableWrap cols={[
|
||||
{ header: "Status / Last Seen", width: "w-[150px]", align: "center" },
|
||||
{ header: "IP Address", width: "min-w-[120px]" },
|
||||
{ header: "Domain", width: "min-w-[160px] w-full" },
|
||||
{ header: "Protocol", width: "w-[80px]", align: "center" },
|
||||
{ header: "Download", width: "w-[100px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[100px]", align: "right" }
|
||||
{ header: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Status / Last Seen", width: "w-[20%]", align: "center" },
|
||||
{ header: "IP Address", width: "w-[20%]", align: "center" },
|
||||
{ header: "Domain", width: "w-[25%]", align: "center" },
|
||||
{ header: "Download", width: "w-[15%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[15%]", align: "center" },
|
||||
]}>
|
||||
{paginatedIps.map((dev: any, i: number) => (
|
||||
{paginatedIps.map((row: any, i: number) => (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
<ActiveDurationDisplay firstSeen={dev.first_seen} lastSeen={dev.last_seen} className="border-0 bg-transparent p-0 mb-0 flex justify-center" />
|
||||
<td className="px-3 py-2.5"><Cell value={startIndex + i + 1} center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<ActiveDurationDisplay firstSeen={row.first_seen} lastSeen={row.last_seen} className="border-0 bg-transparent p-0 mb-0 flex justify-center" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<button
|
||||
onClick={() => setSelectedIp(dev.ip_address || dev.dst_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none truncate text-left"
|
||||
title={dev.ip_address || dev.dst_ip || '-'}
|
||||
onClick={() => setSelectedIp(row.ip_address || row.dst_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-xs focus:outline-none w-full block text-center"
|
||||
title={row.ip_address || row.dst_ip || '-'}
|
||||
>
|
||||
{dev.ip_address || dev.dst_ip || '-'}
|
||||
{row.ip_address || row.dst_ip || '-'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={dev.domain || '-'} className="text-slate-300" />
|
||||
<Cell value={row.domain || '-'} className="text-slate-300" center />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={dev.protocol || '-'} className="text-slate-400 font-mono uppercase text-[10px]" center />
|
||||
<Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono" center />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={fmtBytes(dev.download)} className="text-cyan-400 font-mono" right />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={fmtBytes(dev.upload)} className="text-green-400 font-mono" right />
|
||||
<Cell value={fmtBytes(row.upload)} className="text-green-400 font-mono" center />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -123,7 +123,7 @@ export function DataTable<T>({
|
||||
setQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-white placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
className="w-full max-w-sm rounded-lg border border-border bg-card/30 backdrop-blur-sm pl-9 pr-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-card/50 transition-all shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -138,16 +138,16 @@ export function DataTable<T>({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/50 backdrop-blur-xl shadow-sm overflow-visible">
|
||||
<div className="w-full overflow-visible">
|
||||
<div className="rounded-xl border border-border bg-card/50 backdrop-blur-xl shadow-sm overflow-hidden">
|
||||
<div className="w-full max-h-[400px] overflow-y-auto custom-scrollbar [&::-webkit-scrollbar-track]:mt-[48px]">
|
||||
<table className="w-full text-sm text-left table-fixed">
|
||||
<thead className="bg-secondary/30 text-muted-foreground border-b border-border/50 relative z-20">
|
||||
<thead className="bg-slate-900 text-muted-foreground border-b border-border/50 sticky top-0 z-20">
|
||||
<tr>
|
||||
{columns.map((col, i) => (
|
||||
<th
|
||||
key={i}
|
||||
onClick={() => col.sortable && handleSort(i)}
|
||||
className={`px-4 py-3.5 h-[48px] font-medium tracking-wide whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-white/5 transition-colors group select-none" : ""}`}
|
||||
className={`px-4 py-3.5 h-[48px] font-medium tracking-wide whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors group select-none" : ""}`}
|
||||
>
|
||||
<div className={`flex items-center gap-1 ${col.className?.includes("text-") && !col.className?.includes("text-center") ? "" : "justify-center"}`}>
|
||||
{col.header}
|
||||
@@ -196,7 +196,7 @@ export function DataTable<T>({
|
||||
className={`hover:bg-primary/5 border-b border-border/50 transition-colors duration-200 group h-[48px] ${onRowClick ? "cursor-pointer hover:bg-primary/10" : ""} ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
>
|
||||
{columns.map((col, j) => (
|
||||
<td key={j} className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground group-hover:text-white transition-colors truncate whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}>
|
||||
<td key={j} className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground transition-colors truncate whitespace-nowrap ${col.className?.includes("text-") ? "" : "text-center"} ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
</td>
|
||||
))}
|
||||
@@ -210,17 +210,17 @@ export function DataTable<T>({
|
||||
{filteredData.length > pageSize && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border/50 bg-card/30">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-white">{startIndex + 1}</span> to{" "}
|
||||
<span className="font-medium text-white">
|
||||
Showing <span className="font-medium text-foreground">{startIndex + 1}</span> to{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{Math.min(startIndex + pageSize, filteredData.length)}
|
||||
</span>{" "}
|
||||
of <span className="font-medium text-white">{filteredData.length}</span> results
|
||||
of <span className="font-medium text-foreground">{filteredData.length}</span> results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-white hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50"
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
@@ -230,7 +230,7 @@ export function DataTable<T>({
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-white hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50"
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-md bg-secondary/80 text-foreground hover:bg-secondary disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-border/50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, Calendar, X } from "lucide-react";
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
interface Props {
|
||||
apps: any[];
|
||||
@@ -15,9 +16,41 @@ export default function DeviceAppsTab({ apps, totalDownload, totalUpload }: Prop
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(apps.length / pageSize);
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(apps.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_label", label: "App", type: "select", value: filters.app_label, options: opts("app_label"), formatter: formatAppLabel },
|
||||
{ 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 = apps.filter((row: any) => {
|
||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_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 totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = apps.slice(startIndex, startIndex + pageSize);
|
||||
const paginated = processedData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
@@ -35,6 +68,24 @@ export default function DeviceAppsTab({ apps, totalDownload, totalUpload }: Prop
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((app: any, i: number) => {
|
||||
const appBytes = app.download + app.upload;
|
||||
@@ -43,7 +94,7 @@ export default function DeviceAppsTab({ apps, totalDownload, totalUpload }: Prop
|
||||
<div key={i} className="bg-slate-50/50 dark:bg-white/[0.03] border border-border p-4 rounded-2xl hover:bg-slate-100/50 dark:hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-blue-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-card-foreground mb-3 text-sm flex justify-between items-center">
|
||||
{app.app_label}
|
||||
{formatAppLabel(app.app_label)}
|
||||
<span className="text-[10px] font-mono text-blue-600 dark:text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-full ring-1 ring-blue-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
@@ -55,19 +106,27 @@ export default function DeviceAppsTab({ apps, totalDownload, totalUpload }: Prop
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(app.upload)}</span>
|
||||
</div>
|
||||
{app.last_seen && (
|
||||
<div className="flex justify-between mt-2 pt-2 border-t border-border/50">
|
||||
<span className="text-muted-foreground">Last Seen</span>
|
||||
<span className="text-muted-foreground font-medium">{new Date(app.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={apps.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
|
||||
useEffect(() => {
|
||||
loadData(false);
|
||||
}, [ip]);
|
||||
}, [ip, mac]);
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "info", label: "Overview", icon: Monitor },
|
||||
@@ -83,13 +83,15 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-card-foreground tracking-tight">{ip}</h3>
|
||||
<h3 className="text-xl font-bold text-card-foreground tracking-tight">
|
||||
{ip || data?.ip_address || 'Resolving IP...'}
|
||||
</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400 animate-spin" />}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{data?.device_label || deviceData?.device_label || 'Unknown Device'}
|
||||
<span className="text-border mx-2">•</span>
|
||||
<span className="font-mono text-xs">{data?.mac_address || deviceData?.mac_address || 'No MAC'}</span>
|
||||
<span className="font-mono text-xs">{mac || data?.mac_address || deviceData?.mac_address || 'No MAC'}</span>
|
||||
</p>
|
||||
{data?.agent_label && (
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1 flex items-center gap-1">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
interface Props {
|
||||
domains: any[];
|
||||
@@ -15,9 +16,41 @@ export default function DeviceDomainsTab({ domains, totalDownload, totalUpload }
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(domains.length / pageSize);
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(domains.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_label", label: "Domain", type: "select", value: filters.app_label, options: opts("app_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 = domains.filter((row: any) => {
|
||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_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 totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = domains.slice(startIndex, startIndex + pageSize);
|
||||
const paginated = processedData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
@@ -35,6 +68,24 @@ export default function DeviceDomainsTab({ domains, totalDownload, totalUpload }
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((dom: any, i: number) => {
|
||||
const appBytes = dom.download + dom.upload;
|
||||
@@ -55,19 +106,27 @@ export default function DeviceDomainsTab({ domains, totalDownload, totalUpload }
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(dom.upload)}</span>
|
||||
</div>
|
||||
{dom.last_seen && (
|
||||
<div className="flex justify-between mt-2 pt-2 border-t border-border/50">
|
||||
<span className="text-muted-foreground">Last Seen</span>
|
||||
<span className="text-muted-foreground font-medium">{new Date(dom.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={domains.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Activity } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
interface Props {
|
||||
flows: any[];
|
||||
@@ -19,15 +20,13 @@ interface ColDef {
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[60vh] rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[800px]">
|
||||
<table className="w-full text-xs text-center table-fixed whitespace-nowrap min-w-[800px]">
|
||||
<thead className="sticky top-0 bg-[#111827] z-10">
|
||||
<tr className="border-b border-border">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] text-center ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
@@ -42,11 +41,11 @@ function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
@@ -55,10 +54,31 @@ function Cell({ value, className = "", center = false, right = false }: { value:
|
||||
export default function DeviceFlowsTab({ flows, ip }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const totalPages = Math.ceil(flows.length / pageSize);
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(flows.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_name", label: "App", type: "select", value: filters.app_name, options: opts("app_name"), formatter: formatAppLabel },
|
||||
{ id: "protocol_name", label: "Protocol", type: "select", value: filters.protocol_name, options: opts("protocol_name"), formatter: formatAppLabel },
|
||||
];
|
||||
|
||||
const filteredData = flows.filter((row: any) => {
|
||||
if (filters.app_name && filters.app_name !== "All" && row.app_name !== filters.app_name) return false;
|
||||
if (filters.protocol_name && filters.protocol_name !== "All" && row.protocol_name !== filters.protocol_name) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = flows.slice(startIndex, startIndex + pageSize);
|
||||
const paginated = filteredData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
if (flows.length === 0) {
|
||||
return (
|
||||
@@ -73,23 +93,32 @@ export default function DeviceFlowsTab({ flows, ip }: Props) {
|
||||
}
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "Time", width: "w-[130px]", align: "center" },
|
||||
{ header: "Source", width: "min-w-[120px]" },
|
||||
{ header: "Destination", width: "min-w-[120px]" },
|
||||
{ header: "Port", width: "w-[80px]", align: "center" },
|
||||
{ header: "App / Protocol", width: "min-w-[150px] w-full" },
|
||||
{ header: "Download", width: "w-[90px]", align: "right" },
|
||||
{ header: "Upload", width: "w-[90px]", align: "right" },
|
||||
{ header: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Time", width: "w-[15%]", align: "center" },
|
||||
{ header: "Source", width: "w-[15%]", align: "center" },
|
||||
{ header: "Destination", width: "w-[15%]", align: "center" },
|
||||
{ header: "Port", width: "w-[10%]", align: "center" },
|
||||
{ header: "App / Protocol", width: "w-[20%]", align: "center" },
|
||||
{ header: "Download", width: "w-[10%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[10%]", align: "center" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => { handleFilterChange(id, val); setPage(1); }}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<TableWrap cols={COLS}>
|
||||
{paginated.map((f: any, i: number) => {
|
||||
const isOutbound = f.src_ip === ip;
|
||||
const timeStr = f.last_seen ? new Date(f.last_seen).toLocaleString() : '-';
|
||||
return (
|
||||
<tr key={i} className="hover:bg-white/[0.04] transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={startIndex + i + 1} center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={timeStr} className="text-muted-foreground" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Cell value={f.src_ip} className={`font-mono ${isOutbound ? 'text-blue-400 font-semibold' : 'text-slate-400'}`} />
|
||||
@@ -101,25 +130,29 @@ export default function DeviceFlowsTab({ flows, ip }: Props) {
|
||||
<Cell value={f.dst_port || '-'} className="text-amber-400/80 font-mono" center />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className="bg-white/10 text-slate-200 px-2 py-0.5 rounded text-[10px] font-medium border border-white/5 truncate max-w-full inline-block align-middle" title={f.app_label || f.protocol || '-'}>
|
||||
{f.app_label || f.protocol || '-'}
|
||||
</span>
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<span className="bg-white/10 text-slate-200 px-2 py-0.5 rounded text-[10px] font-medium border border-white/5 truncate max-w-full inline-block align-middle" title={f.app_label || f.protocol || '-'}>
|
||||
{f.app_label || f.protocol || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" right /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.download)} className="text-cyan-400 font-mono" center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={fmtBytes(f.upload)} className="text-emerald-400 font-mono" center /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
{totalPages > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={flows.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<div className="pt-2">
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={filteredData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,35 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
|
||||
const PORT_ALIASES: Record<string, string> = {
|
||||
"20": "FTP Data", "21": "FTP Control", "22": "SSH", "23": "Telnet",
|
||||
"25": "SMTP", "53": "DNS", "67": "DHCP Server", "68": "DHCP Client",
|
||||
"80": "HTTP", "110": "POP3", "123": "NTP", "143": "IMAP", "161": "SNMP",
|
||||
"443": "HTTPS", "445": "SMB", "500": "IKE/IPsec", "1433": "MS SQL Server",
|
||||
"1521": "Oracle DB", "3306": "MySQL", "3389": "RDP", "5432": "PostgreSQL",
|
||||
"6379": "Redis", "8080": "HTTP Alternate", "8443": "HTTPS Alternate", "5222": "XMPP/Jabber"
|
||||
};
|
||||
|
||||
function formatProtocolLabel(label: string) {
|
||||
if (label.startsWith("Port ")) {
|
||||
const portNum = label.split(" ")[1];
|
||||
if (PORT_ALIASES[portNum]) {
|
||||
return `Port ${portNum} (${PORT_ALIASES[portNum]})`;
|
||||
}
|
||||
const portInt = parseInt(portNum, 10);
|
||||
if (!isNaN(portInt)) {
|
||||
if (portInt >= 49152 && portInt <= 65535) {
|
||||
return `Port ${portNum} (Ephemeral/Dynamic)`;
|
||||
} else {
|
||||
return `Port ${portNum} (Unassigned/Other)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
interface Props {
|
||||
protocols: any[];
|
||||
@@ -42,9 +16,41 @@ export default function DeviceProtocolsTab({ protocols, totalDownload, totalUplo
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(protocols.length / pageSize);
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(protocols.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "app_label", label: "Protocol", type: "select", value: filters.app_label, options: opts("app_label"), formatter: formatAppLabel },
|
||||
{ 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 = protocols.filter((row: any) => {
|
||||
if (filters.app_label && filters.app_label !== "All" && row.app_label !== filters.app_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 totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = protocols.slice(startIndex, startIndex + pageSize);
|
||||
const paginated = processedData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
@@ -62,6 +68,24 @@ export default function DeviceProtocolsTab({ protocols, totalDownload, totalUplo
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((proto: any, i: number) => {
|
||||
const appBytes = proto.download + proto.upload;
|
||||
@@ -70,8 +94,8 @@ export default function DeviceProtocolsTab({ protocols, totalDownload, totalUplo
|
||||
<div key={i} className="bg-slate-50/50 dark:bg-white/[0.03] border border-border p-4 rounded-2xl hover:bg-slate-100/50 dark:hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-amber-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-card-foreground mb-3 text-sm flex justify-between items-center">
|
||||
{formatProtocolLabel(proto.app_label)}
|
||||
<span className="text-[10px] font-mono text-amber-600 dark:text-amber-400 bg-amber-500/10 px-2 py-0.5 rounded-full ring-1 ring-amber-500/20">{pct}%</span>
|
||||
{formatAppLabel(proto.app_label)}
|
||||
<span className="text-[10px] font-mono text-purple-600 dark:text-purple-400 bg-purple-500/10 px-2 py-0.5 rounded-full ring-1 ring-purple-500/20">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
@@ -82,19 +106,27 @@ export default function DeviceProtocolsTab({ protocols, totalDownload, totalUplo
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(proto.upload)}</span>
|
||||
</div>
|
||||
{proto.last_seen && (
|
||||
<div className="flex justify-between mt-2 pt-2 border-t border-border/50">
|
||||
<span className="text-muted-foreground">Last Seen</span>
|
||||
<span className="text-muted-foreground font-medium">{new Date(proto.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={protocols.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Shield } from "lucide-react";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
interface Props {
|
||||
threats: any[];
|
||||
@@ -17,15 +18,13 @@ interface ColDef {
|
||||
function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef[] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[60vh] rounded-xl border border-border/50 bg-secondary/10 custom-scrollbar">
|
||||
<table className="w-full text-xs text-left whitespace-nowrap min-w-[700px]">
|
||||
<table className="w-full text-xs text-center table-fixed whitespace-nowrap min-w-[700px]">
|
||||
<thead className="sticky top-0 bg-[#111827] z-10">
|
||||
<tr className="border-b border-border">
|
||||
{cols.map((c) => (
|
||||
<th
|
||||
key={c.header}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] ${
|
||||
c.align === "center" ? "text-center" : c.align === "right" ? "text-right" : "text-left"
|
||||
} ${c.width}`}
|
||||
className={`px-3 py-2.5 font-medium text-muted-foreground uppercase tracking-wider text-[10px] text-center ${c.width}`}
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
@@ -40,11 +39,11 @@ function TableWrap({ children, cols }: { children: React.ReactNode; cols: ColDef
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = false, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
function Cell({ value, className = "", center = true, right = false }: { value: string | number; className?: string; center?: boolean; right?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
const align = center ? "text-center" : right ? "text-right" : "";
|
||||
const align = center ? "text-center" : right ? "text-right" : "text-center";
|
||||
return (
|
||||
<div className={`truncate overflow-hidden text-ellipsis ${align} ${className}`} title={str}>
|
||||
<div className={`truncate overflow-hidden text-ellipsis flex items-center justify-center w-full ${align} ${className}`} title={str}>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
@@ -54,9 +53,31 @@ export default function DeviceThreatsTab({ threats }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const totalPages = Math.ceil(threats.length / pageSize);
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(threats.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: "threat_type", label: "Threat Type", type: "select", value: filters.threat_type, options: opts("threat_type") },
|
||||
];
|
||||
|
||||
const filteredData = threats.filter((row: any) => {
|
||||
if (filters.severity && filters.severity !== "All" && row.severity !== filters.severity) return false;
|
||||
if (filters.threat_type && filters.threat_type !== "All" && row.threat_type !== filters.threat_type) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = threats.slice(startIndex, startIndex + pageSize);
|
||||
const paginated = filteredData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
if (threats.length === 0) {
|
||||
return (
|
||||
@@ -71,14 +92,22 @@ export default function DeviceThreatsTab({ threats }: Props) {
|
||||
}
|
||||
|
||||
const COLS: ColDef[] = [
|
||||
{ header: "Time", width: "w-[150px]", align: "center" },
|
||||
{ header: "Type", width: "min-w-[150px] w-full" },
|
||||
{ header: "Severity", width: "w-[100px]", align: "center" },
|
||||
{ header: "Target IP", width: "min-w-[120px]" },
|
||||
{ header: "#", width: "w-[5%]", align: "center" },
|
||||
{ header: "Time", width: "w-[25%]", align: "center" },
|
||||
{ header: "Type", width: "w-[40%]", align: "center" },
|
||||
{ header: "Severity", width: "w-[15%]", align: "center" },
|
||||
{ header: "Target IP", width: "w-[15%]", align: "center" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => { handleFilterChange(id, val); setPage(1); }}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<TableWrap cols={COLS}>
|
||||
{paginated.map((t: any, i: number) => {
|
||||
const sev = (t.severity || '').toLowerCase();
|
||||
@@ -89,13 +118,16 @@ export default function DeviceThreatsTab({ threats }: Props) {
|
||||
const timeStr = t.detected_at ? new Date(t.detected_at).toLocaleString() : '-';
|
||||
return (
|
||||
<tr key={i} className="hover:bg-red-500/5 transition-colors duration-150">
|
||||
<td className="px-3 py-2.5"><Cell value={startIndex + i + 1} center /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={timeStr} className="text-muted-foreground" center /></td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div
|
||||
title={t.description || t.threat_type || "-"}
|
||||
className="font-semibold text-card-foreground text-xs truncate max-w-full cursor-help hover:text-primary transition-colors block"
|
||||
>
|
||||
{t.threat_type || "-"}
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<div
|
||||
title={t.description || t.threat_type || "-"}
|
||||
className="font-semibold text-card-foreground text-xs truncate max-w-full cursor-help hover:text-primary transition-colors block text-center"
|
||||
>
|
||||
{t.threat_type || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center">
|
||||
@@ -103,20 +135,22 @@ export default function DeviceThreatsTab({ threats }: Props) {
|
||||
{t.severity || "Unknown"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5"><Cell value={t.dst_ip || t.ip_address || "-"} className="font-mono text-muted-foreground" /></td>
|
||||
<td className="px-3 py-2.5"><Cell value={t.dst_ip || t.ip_address || "-"} className="font-mono text-muted-foreground" center /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</TableWrap>
|
||||
{totalPages > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={threats.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
{totalPages > 1 && (
|
||||
<div className="pt-2">
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={filteredData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { HelpCircle, X, Search, PlayCircle, ChevronDown, ChevronUp, FileText, BookOpen } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
const FAQItem = ({ question, answer }: { question: string, answer: string }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border/50 py-3">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex w-full items-center justify-between text-left focus:outline-none"
|
||||
>
|
||||
<span className="text-sm font-medium text-foreground">{question}</span>
|
||||
{isOpen ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="pt-2 text-sm text-muted-foreground leading-relaxed">
|
||||
{answer}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function HelpCenterFAB() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: "Kenapa data Threats kosong (0)?",
|
||||
a: "Jika indikator Threats menunjukkan angka 0, artinya jaringan Anda aman dalam rentang waktu yang dipilih. Sistem tidak mendeteksi adanya paket data yang cocok dengan profil serangan yang diketahui."
|
||||
},
|
||||
{
|
||||
q: "Apa perbedaan Events dan Threats?",
|
||||
a: "Events adalah semua log aktivitas koneksi biasa yang tercatat di jaringan Anda (termasuk yang aman). Threats adalah sekumpulan Events khusus yang telah difilter dan diklasifikasikan sebagai aktivitas berbahaya oleh sistem Deep Packet Inspection (DPI)."
|
||||
},
|
||||
{
|
||||
q: "Bagaimana cara membaca Heatmap Geo Traffic?",
|
||||
a: "Warna biru muda menunjukkan trafik rendah, sedangkan warna biru pekat/tua menunjukkan volume trafik (download/upload) yang sangat tinggi dari/ke negara tersebut."
|
||||
},
|
||||
{
|
||||
q: "Berapa lama data disimpan?",
|
||||
a: "Untuk menjaga performa sistem, database MongoDB dikonfigurasi untuk secara otomatis menghapus data telemetri jaringan yang usianya lebih dari 7 hari."
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating Action Button */}
|
||||
<motion.button
|
||||
onClick={() => setIsOpen(true)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="fixed bottom-6 right-6 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-[0_0_20px_rgba(37,99,235,0.4)] hover:bg-blue-500 transition-colors"
|
||||
aria-label="Help and Resources"
|
||||
>
|
||||
<HelpCircle className="h-6 w-6" />
|
||||
{/* Pulse effect */}
|
||||
<span className="absolute -inset-1 animate-ping rounded-full bg-blue-400 opacity-20"></span>
|
||||
</motion.button>
|
||||
|
||||
{/* Drawer Overlay */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="fixed inset-0 z-50 bg-background/40 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
{/* Drawer Panel */}
|
||||
<motion.div
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
className="fixed right-0 top-0 z-50 flex h-full w-full max-w-sm flex-col bg-card/95 backdrop-blur-xl border-l border-border shadow-2xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/50 px-6 py-5">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-foreground">Help & Resources</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">BackOne Guide & Documentation</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="rounded-lg p-2 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-8">
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search articles or FAQs..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full rounded-xl border border-border bg-secondary/50 py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Getting Started Video Cards */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
<PlayCircle className="h-4 w-4" /> Getting Started
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="group cursor-pointer space-y-2">
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-slate-800 dark:bg-slate-900 border border-border flex items-center justify-center group-hover:border-blue-500 transition-colors">
|
||||
<PlayCircle className="h-8 w-8 text-white/70 group-hover:text-blue-400 group-hover:scale-110 transition-all" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-foreground line-clamp-2 leading-tight">Dashboard Overview Tour</p>
|
||||
</div>
|
||||
<div className="group cursor-pointer space-y-2">
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-slate-800 dark:bg-slate-900 border border-border flex items-center justify-center group-hover:border-blue-500 transition-colors">
|
||||
<PlayCircle className="h-8 w-8 text-white/70 group-hover:text-blue-400 group-hover:scale-110 transition-all" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-foreground line-clamp-2 leading-tight">How to Analyze Metadata</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentation Links */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" /> Quick Links
|
||||
</h3>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-secondary transition-colors text-left">
|
||||
<FileText className="h-4 w-4 text-blue-500" />
|
||||
Read the Full Documentation
|
||||
</button>
|
||||
<button className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-secondary transition-colors text-left">
|
||||
<FileText className="h-4 w-4 text-emerald-500" />
|
||||
Integration with BackOne API
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQs */}
|
||||
<div className="space-y-3 pb-8">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
<HelpCircle className="h-4 w-4" /> Frequently Asked Questions
|
||||
</h3>
|
||||
<div className="rounded-xl border border-border/50 bg-card px-4">
|
||||
{faqs.map((faq, index) => (
|
||||
<FAQItem key={index} question={faq.q} answer={faq.a} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border/50 bg-secondary/30 p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
BackOne © 2026<br/>
|
||||
PT. Data Bisnis Solusi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export function IpDetails({ ip }: { ip: string }) {
|
||||
const isLocal = details.country === "Local";
|
||||
|
||||
return (
|
||||
<div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate">
|
||||
<div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate" title={`${details.isp}${!isLocal ? ` • ${details.country}` : ''}`}>
|
||||
<span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}>
|
||||
{details.isp}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchRemoteIpDetails } from "@/lib/api";
|
||||
import { RemoteIpDetails } from "@/lib/api-detail-types";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, X, Globe, Activity, Shield, Box, Server, Clock, ArrowDownToLine, ArrowUpFromLine, RefreshCcw, Link2, Laptop } from "lucide-react";
|
||||
import ActiveDurationDisplay from "./ActiveDurationDisplay";
|
||||
import { IpDetails } from "./IpDetails";
|
||||
|
||||
import RemoteIpLocalDevicesTab from "./RemoteIpLocalDevicesTab";
|
||||
import DeviceProtocolsTab from "./DeviceProtocolsTab";
|
||||
import DeviceDomainsTab from "./DeviceDomainsTab";
|
||||
import DeviceFlowsTab from "./DeviceFlowsTab";
|
||||
import DeviceThreatsTab from "./DeviceThreatsTab";
|
||||
|
||||
interface Props {
|
||||
ip: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "info" | "local_devices" | "protocols" | "domains" | "flows" | "threats";
|
||||
|
||||
export function RemoteIpDetailModal({ ip, onClose }: Props) {
|
||||
const [data, setData] = useState<RemoteIpDetails | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("info");
|
||||
|
||||
const handleClose = () => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => onClose(), 480);
|
||||
};
|
||||
|
||||
const loadData = async (silent = false) => {
|
||||
if (!silent) setIsLoading(true);
|
||||
else setIsRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchRemoteIpDetails(ip, '1d');
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
if (!silent) setError(e.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData(false);
|
||||
}, [ip]);
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "info", label: "Overview", icon: Globe },
|
||||
{ id: "local_devices", label: "Local Devices", icon: Laptop },
|
||||
{ id: "protocols", label: "Protocols", icon: Link2 },
|
||||
{ id: "domains", label: "Domains", icon: Box },
|
||||
{ id: "flows", label: "Flows", icon: Activity },
|
||||
{ id: "threats", label: "Threats", icon: Shield }
|
||||
];
|
||||
|
||||
return (
|
||||
<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-3xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/10 dark:shadow-blue-900/20 overflow-hidden backdrop-blur-xl ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-indigo-500/50 to-transparent" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-border bg-gradient-to-br from-indigo-500/5 via-slate-500/5 to-slate-500/10 dark:from-indigo-900/10 dark:via-slate-900/20 dark:to-slate-950/40 relative z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-indigo-500 blur-xl opacity-20 rounded-full" />
|
||||
<div className="p-3 bg-gradient-to-br from-indigo-500/20 to-blue-500/10 rounded-xl ring-1 ring-indigo-500/30 relative">
|
||||
<Globe className="w-6 h-6 text-indigo-500 dark:text-indigo-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-card-foreground tracking-tight">{ip}</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3.5 h-3.5 text-indigo-500 dark:text-indigo-400 animate-spin" />}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<IpDetails ip={ip} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => loadData(true)}
|
||||
disabled={isLoading || isRefreshing}
|
||||
className="p-2.5 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-full transition-all disabled:opacity-50"
|
||||
title="Refresh Data"
|
||||
>
|
||||
<RefreshCcw className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 rounded-full transition-all bg-black/5 dark:bg-white/5"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex px-6 border-b border-border bg-muted/20 overflow-x-auto no-scrollbar">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-5 py-4 border-b-2 font-medium text-sm transition-all whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-indigo-500 text-indigo-600 dark:text-indigo-400 bg-indigo-500/5'
|
||||
: 'border-transparent text-muted-foreground hover:text-card-foreground hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className={`w-4 h-4 ${activeTab === tab.id ? '' : 'opacity-70'}`} />
|
||||
{tab.label}
|
||||
{data && tab.id === 'flows' && data.flows.length > 0 && (
|
||||
<span className="ml-1.5 px-2 py-0.5 rounded-full bg-slate-200 dark:bg-slate-800 text-[10px] text-muted-foreground font-semibold">
|
||||
{data.flows.length}
|
||||
</span>
|
||||
)}
|
||||
{data && tab.id === 'threats' && data.threats.length > 0 && (
|
||||
<span className="ml-1.5 px-2 py-0.5 rounded-full bg-destructive/10 text-destructive text-[10px] font-semibold ring-1 ring-destructive/20">
|
||||
{data.threats.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 bg-gradient-to-b from-transparent to-muted/10 relative">
|
||||
{isLoading ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 animate-in fade-in duration-500">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Loading remote IP intelligence...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-destructive">
|
||||
<Shield className="w-10 h-10 mb-2 opacity-50" />
|
||||
<p className="font-semibold text-lg">Error loading data</p>
|
||||
<p className="text-sm opacity-80">{error}</p>
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="h-full animate-in fade-in slide-in-from-bottom-2 duration-500">
|
||||
{activeTab === "info" && (
|
||||
<div className="space-y-6">
|
||||
<ActiveDurationDisplay firstSeen={data.first_seen || undefined} lastSeen={data.last_seen || undefined} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-6 bg-gradient-to-br from-cyan-500/5 dark:from-cyan-500/10 to-transparent border border-cyan-100 dark:border-cyan-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowDownToLine className="w-16 h-16 text-cyan-500 dark:text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4" /> Total Download (to Remote IP)
|
||||
</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-br from-emerald-500/5 dark:from-emerald-500/10 to-transparent border border-emerald-100 dark:border-emerald-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowUpFromLine className="w-16 h-16 text-emerald-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowUpFromLine className="w-4 h-4" /> Total Upload (from Remote IP)
|
||||
</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6">
|
||||
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-indigo-500" /> Network Properties
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Globe className="w-3.5 h-3.5" /> IP Version</p>
|
||||
<p className="text-card-foreground font-medium text-sm">IPv{data.ip_version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Laptop className="w-3.5 h-3.5" /> Interacting Devices</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.local_devices?.length || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Link2 className="w-3.5 h-3.5" /> Flow Count</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.flows?.length || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 mb-1.5"><Clock className="w-3.5 h-3.5" /> Last Seen</p>
|
||||
<p className="text-card-foreground font-medium text-sm">{data.last_seen ? new Date(data.last_seen).toLocaleString() : 'Never'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "local_devices" && (
|
||||
<RemoteIpLocalDevicesTab devices={data.local_devices} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{activeTab === "protocols" && (
|
||||
<DeviceProtocolsTab protocols={data.protocols} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{activeTab === "domains" && (
|
||||
<DeviceDomainsTab domains={data.domains} totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
)}
|
||||
|
||||
{activeTab === "flows" && (
|
||||
<DeviceFlowsTab flows={data.flows} ip={ip} />
|
||||
)}
|
||||
|
||||
{activeTab === "threats" && (
|
||||
<DeviceThreatsTab threats={data.threats} />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Laptop } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Pagination } from "./Pagination";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "./UniversalFilters";
|
||||
|
||||
interface Props {
|
||||
devices: any[];
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function RemoteIpLocalDevicesTab({ devices, totalDownload, totalUpload }: Props) {
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 50;
|
||||
|
||||
const {
|
||||
filters,
|
||||
handleFilterChange,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ 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"] },
|
||||
];
|
||||
|
||||
let processedData = [...devices];
|
||||
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 totalPages = Math.ceil(processedData.length / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const paginated = processedData.slice(startIndex, startIndex + pageSize);
|
||||
|
||||
const totalBytes = totalDownload + totalUpload;
|
||||
|
||||
if (devices.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<Laptop className="w-12 h-12 text-muted-foreground/60 mx-auto mb-3" />
|
||||
<h4 className="text-card-foreground font-bold text-lg mb-1">No Local Devices</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
No local devices have communicated with this remote IP in the selected time range.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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);
|
||||
}
|
||||
setPage(1);
|
||||
}}
|
||||
showDateRange={false}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={() => { handleReset(); setPage(1); }}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{paginated.map((dev: any, i: number) => {
|
||||
const appBytes = dev.download + dev.upload;
|
||||
const pct = totalBytes > 0 ? ((appBytes / totalBytes) * 100).toFixed(1) : "0";
|
||||
return (
|
||||
<div key={i} className="bg-slate-50/50 dark:bg-white/[0.03] border border-border p-4 rounded-2xl hover:bg-slate-100/50 dark:hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-indigo-500/30 transition-all duration-500" style={{ width: `${pct}%` }} />
|
||||
<h5 className="font-semibold text-card-foreground mb-3 text-sm flex justify-between items-center truncate gap-2" title={dev.app_label}>
|
||||
<span className="truncate text-blue-400 font-mono">{dev.app_label}</span>
|
||||
<span className="text-[10px] font-mono text-indigo-600 dark:text-indigo-400 bg-indigo-500/10 px-2 py-0.5 rounded-full ring-1 ring-indigo-500/20 shrink-0">{pct}%</span>
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="text-cyan-600 dark:text-cyan-400 font-mono font-medium">{fmtBytes(dev.download)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-mono font-medium">{fmtBytes(dev.upload)}</span>
|
||||
</div>
|
||||
{dev.last_seen && (
|
||||
<div className="flex justify-between mt-2 pt-2 border-t border-border/50">
|
||||
<span className="text-muted-foreground">Last Seen</span>
|
||||
<span className="text-muted-foreground font-medium">{new Date(dev.last_seen).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
totalResults={processedData.length}
|
||||
startIndex={startIndex}
|
||||
endIndex={startIndex + pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
interface SafeImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
}
|
||||
|
||||
export function SafeImage({ src, ...props }: SafeImageProps) {
|
||||
const [imageSrc, setImageSrc] = useState<string>("");
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src || src.endsWith("/undefined") || src.endsWith("/null")) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
let objectUrl = "";
|
||||
|
||||
fetch(src)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Failed to load image");
|
||||
return res.blob();
|
||||
})
|
||||
.then((blob) => {
|
||||
if (!active) return;
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageSrc(objectUrl);
|
||||
setError(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("Failed to load profile image gracefully:", err.message);
|
||||
if (active) setError(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
if (error || !imageSrc) {
|
||||
return (
|
||||
<div
|
||||
className={props.className}
|
||||
style={{
|
||||
backgroundColor: "rgba(30, 41, 59, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">
|
||||
Profile
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={imageSrc} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { formatTimestampWIB, calculateDuration, getActiveStatus } from "@/lib/utils";
|
||||
|
||||
interface TimestampCellProps {
|
||||
timestamp: string | Date | null | undefined;
|
||||
firstSeen?: string | Date | null | undefined;
|
||||
showActiveStatus?: boolean;
|
||||
}
|
||||
|
||||
export function TimestampCell({ timestamp, firstSeen, showActiveStatus = true }: TimestampCellProps) {
|
||||
if (!timestamp) {
|
||||
return <span className="text-muted-foreground text-xs">-</span>;
|
||||
}
|
||||
|
||||
const formattedTime = formatTimestampWIB(timestamp);
|
||||
const splitTime = formattedTime.split(' '); // toLocaleString('id-ID') separates date and time with a space usually
|
||||
const dateStr = splitTime[0] || formattedTime;
|
||||
const timeStr = splitTime[1] || "";
|
||||
|
||||
const isActive = showActiveStatus ? getActiveStatus(timestamp) : false;
|
||||
const duration = firstSeen ? calculateDuration(firstSeen, timestamp) : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5 whitespace-nowrap">
|
||||
<span className="text-sm font-medium">{dateStr}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{timeStr}</span>
|
||||
|
||||
{showActiveStatus && (
|
||||
<span className={`text-[9px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-full mt-0.5 ${isActive ? "bg-emerald-500/20 text-emerald-400" : "bg-slate-500/20 text-slate-400"}`}>
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{duration && duration !== "-" && (
|
||||
<span className="text-[10px] font-mono text-blue-400/80 mt-0.5" title={`Duration: ${duration}`}>
|
||||
⏱ {duration}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DropdownFilterSelect } from "./DropdownFilterSelect";
|
||||
import { DateRangePicker } from "./DateRangePicker";
|
||||
|
||||
export interface FilterConfig {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'select' | 'date-range';
|
||||
value?: string;
|
||||
options?: string[]; // For select type
|
||||
placeholder?: string;
|
||||
formatter?: (val: string) => string; // For rendering option labels
|
||||
}
|
||||
|
||||
export interface UniversalFiltersProps {
|
||||
filters: FilterConfig[];
|
||||
onChange: (id: string, value: string) => void;
|
||||
|
||||
// Date range state
|
||||
showDateRange?: boolean;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
minDate?: string;
|
||||
onDateChange?: (from: string, to: string) => void;
|
||||
|
||||
activeFilterCount: number;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function UniversalFilters({
|
||||
filters,
|
||||
onChange,
|
||||
showDateRange = false,
|
||||
dateFrom = "",
|
||||
dateTo = "",
|
||||
minDate = "2024-01-01",
|
||||
onDateChange,
|
||||
activeFilterCount,
|
||||
onReset,
|
||||
}: UniversalFiltersProps) {
|
||||
|
||||
if (filters.length === 0 && !showDateRange) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 relative z-30 overflow-visible mb-6 animate-slide-up">
|
||||
<CardHeader className="border-b border-border/50 pb-3 flex flex-row items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||
Filters
|
||||
</CardTitle>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-2.5 py-0.5 rounded-full font-semibold">
|
||||
{activeFilterCount} active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{activeFilterCount > 0 && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-xs font-medium text-red-400 hover:text-red-300 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Reset Filters
|
||||
</button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-4 overflow-visible">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 overflow-visible">
|
||||
{/* Optional Date Range */}
|
||||
{showDateRange && onDateChange && (
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Date Range
|
||||
</label>
|
||||
<DateRangePicker
|
||||
minDate={minDate}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onChange={onDateChange}
|
||||
onClear={() => onDateChange("", "")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render mapped filters */}
|
||||
{filters.map((f) => {
|
||||
if (f.type === 'select') {
|
||||
return (
|
||||
<div key={f.id} className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
{f.label}
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={f.value || "All"}
|
||||
onChange={(val) => onChange(f.id, val)}
|
||||
options={f.options || []}
|
||||
placeholder={f.placeholder || `All ${f.label}s`}
|
||||
optionFormatter={f.formatter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function useUniversalFilterState() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
|
||||
const handleFilterChange = (id: string, val: string) => {
|
||||
setFilters(prev => ({ ...prev, [id]: val }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({});
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
};
|
||||
|
||||
const activeCount = Object.values(filters).filter(v => v && v !== "All").length + (dateFrom || dateTo ? 1 : 0);
|
||||
|
||||
return { filters, dateFrom, dateTo, handleFilterChange, setDateFrom, setDateTo, handleReset, activeCount };
|
||||
}
|
||||
+123
-47
@@ -12,55 +12,90 @@ import {
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
// The topojson file containing the world shapes
|
||||
const geoUrl = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
interface WorldMapProps {
|
||||
data: {
|
||||
countryCode: string; // ISO A2 code
|
||||
value: number; // The value used for heatmap
|
||||
label: string; // Country name for tooltip
|
||||
countryCode: string;
|
||||
value: number;
|
||||
download?: number;
|
||||
upload?: number;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
const [tooltipContent, setTooltipContent] = useState("");
|
||||
const [maxValue, setMaxValue] = useState(0);
|
||||
const [tooltipData, setTooltipData] = useState<any>(null);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
const [logBounds, setLogBounds] = useState({ min: 0, max: 1 });
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
const [position, setPosition] = useState({ coordinates: [0, 30] as [number, number], zoom: 1 });
|
||||
|
||||
const { theme } = useTheme();
|
||||
const isLight = theme === 'light';
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (data.length > 0) {
|
||||
const max = Math.max(...data.map(d => d.value));
|
||||
setMaxValue(max);
|
||||
const logs = data.filter(d => d.value > 0).map(d => Math.log10(d.value));
|
||||
if (logs.length > 0) {
|
||||
const minLog = Math.min(...logs);
|
||||
const maxLog = Math.max(...logs);
|
||||
setLogBounds({
|
||||
min: minLog,
|
||||
max: maxLog > minLog ? maxLog : minLog + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const { min, max } = logBounds;
|
||||
const step = (max - min) / 4;
|
||||
const colorScale = scaleLinear<string>()
|
||||
.domain(maxValue > 0 ? [0, maxValue * 0.33, maxValue * 0.66, maxValue] : [0, 1, 2, 3])
|
||||
.domain([min, min + step, min + step * 2, min + step * 3, max])
|
||||
.range(isLight
|
||||
? ["#bbf7d0", "#fef08a", "#fb923c", "#ef4444"] // Light: Green -> Yellow -> Orange -> Red
|
||||
: ["#1e293b", "#0f172a", "#0284c7", "#38bdf8"] // Dark: Slate -> Sky
|
||||
? ["#e0f2fe", "#bae6fd", "#7dd3fc", "#38bdf8", "#0284c7"] // Light Mode: Clean Sky Blues
|
||||
: ["#0f172a", "#1e3a8a", "#1d4ed8", "#2563eb", "#60a5fa"] // Dark Mode: Deep Blues
|
||||
);
|
||||
|
||||
const defaultLandColor = isLight ? "#86efac" : "#1e293b";
|
||||
const oceanColor = isLight ? "#bfdbfe" : "transparent";
|
||||
const strokeColor = isLight ? "rgba(0,0,0,0.15)" : "rgba(255,255,255,0.1)";
|
||||
const defaultLandColor = isLight ? "#f1f5f9" : "#111827";
|
||||
const oceanColor = isLight ? "#f8fafc" : "transparent";
|
||||
const strokeColor = isLight ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.05)";
|
||||
|
||||
function handleZoomIn() {
|
||||
if (position.zoom >= 4) return;
|
||||
setPosition((pos) => ({ ...pos, zoom: pos.zoom * 1.5 }));
|
||||
}
|
||||
|
||||
function handleZoomOut() {
|
||||
if (position.zoom <= 1) return;
|
||||
setPosition((pos) => ({ ...pos, zoom: pos.zoom / 1.5 }));
|
||||
}
|
||||
|
||||
function handleMoveEnd(pos: { coordinates: [number, number]; zoom: number }) {
|
||||
setPosition(pos);
|
||||
}
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="relative w-full aspect-[2/1] bg-secondary/20 rounded-xl overflow-hidden border border-border/50 backdrop-blur-sm">
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-card/80 p-3 rounded-lg border border-border/50 backdrop-blur-md">
|
||||
<div className="text-sm font-medium text-white flex items-center gap-2">
|
||||
Heatmap Activity
|
||||
<div
|
||||
className="relative w-full aspect-[2/1] bg-card rounded-xl overflow-hidden border border-border/50 shadow-inner group"
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
}}
|
||||
onMouseLeave={() => setTooltipData(null)}
|
||||
>
|
||||
{/* Legend */}
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1 pointer-events-none bg-background/80 p-3 rounded-lg border border-border/50 backdrop-blur-md shadow-lg">
|
||||
<div className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
Heatmap Intensity
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="text-xs text-muted-foreground">Low</div>
|
||||
@@ -68,41 +103,85 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
className="w-24 h-2 rounded-full shadow-sm"
|
||||
style={{
|
||||
background: isLight
|
||||
? 'linear-gradient(to right, #bbf7d0, #fef08a, #fb923c, #ef4444)'
|
||||
: 'linear-gradient(to right, #1e293b, #38bdf8)'
|
||||
? 'linear-gradient(to right, #e0f2fe, #bae6fd, #7dd3fc, #38bdf8, #0284c7)'
|
||||
: 'linear-gradient(to right, #1e3a8a, #1d4ed8, #2563eb, #60a5fa)'
|
||||
}}
|
||||
></div>
|
||||
<div className="text-xs text-muted-foreground">High</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tooltip Overlay */}
|
||||
{tooltipContent && (
|
||||
<div className="absolute bottom-4 left-4 z-10 bg-card/90 backdrop-blur-md border border-border/50 px-4 py-2.5 rounded-lg shadow-lg pointer-events-none animate-fade-in">
|
||||
<div className="text-sm font-semibold text-white">{tooltipContent.split(':')[0]}</div>
|
||||
<div className="text-xs text-sky-400 mt-1 font-medium">{tooltipContent.split(':')[1]}</div>
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute bottom-4 right-4 z-10 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm"
|
||||
title="Zoom In"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-2 bg-background/80 hover:bg-secondary border border-border/50 rounded-lg backdrop-blur-md text-foreground transition-colors shadow-sm"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Floating Mouse Tooltip */}
|
||||
{tooltipData && (
|
||||
<div
|
||||
style={{ top: mousePos.y + 15, left: mousePos.x + 15 }}
|
||||
className="absolute z-50 bg-background/95 backdrop-blur-xl border border-border px-4 py-3 rounded-xl shadow-2xl pointer-events-none animate-in fade-in zoom-in-95 duration-100 min-w-[200px]"
|
||||
>
|
||||
<div className="text-sm font-bold text-foreground mb-2 flex items-center gap-2 border-b border-border/50 pb-2">
|
||||
{tooltipData.label}
|
||||
</div>
|
||||
{tooltipData.value > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Download</span>
|
||||
<span className="font-semibold text-sky-400">{fmtBytes(tooltipData.download || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Upload</span>
|
||||
<span className="font-semibold text-emerald-400">{fmtBytes(tooltipData.upload || 0)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs pt-1 mt-1 border-t border-border/30">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="font-bold text-rose-400">{fmtBytes(tooltipData.value)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground italic">No traffic recorded</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{
|
||||
scale: 130,
|
||||
scale: 140,
|
||||
center: [0, 30]
|
||||
}}
|
||||
width={800}
|
||||
height={400}
|
||||
className="w-full h-full"
|
||||
className="w-full h-full outline-none cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<ZoomableGroup zoom={1} maxZoom={4} translateExtent={[[-100, -100], [900, 500]]}>
|
||||
<ZoomableGroup
|
||||
zoom={position.zoom}
|
||||
center={position.coordinates}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
maxZoom={6}
|
||||
translateExtent={[[-200, -100], [1000, 600]]}
|
||||
>
|
||||
<Sphere stroke={strokeColor} strokeWidth={0.5} id="sphere" fill={oceanColor} />
|
||||
<Graticule stroke={strokeColor} strokeWidth={0.5} />
|
||||
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => {
|
||||
// geo.properties.name contains the country name in this TopoJSON
|
||||
// Try to find if we have data for this country
|
||||
const d = data.find((s) => {
|
||||
if (!geo.properties.name) return false;
|
||||
const geoName = geo.properties.name.toLowerCase();
|
||||
@@ -114,33 +193,30 @@ const WorldMapComponent = ({ data }: WorldMapProps) => {
|
||||
(sLabel === 'south korea' && geoName === 'korea');
|
||||
});
|
||||
|
||||
const color = d ? colorScale(d.value) : defaultLandColor;
|
||||
const hasData = d && d.value > 0;
|
||||
const fillColor = hasData ? colorScale(Math.log10(d.value)) : defaultLandColor;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={color}
|
||||
fill={fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={0.5}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
default: { outline: "none", transition: "fill 300ms" },
|
||||
hover: {
|
||||
fill: "#0ea5e9", // sky-500 on hover
|
||||
fill: hasData ? "#38bdf8" : (isLight ? "#e2e8f0" : "#334155"), // Sky-400 glow or fallback
|
||||
stroke: "#ffffff",
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
transition: "all 200ms ease"
|
||||
transition: "all 150ms ease",
|
||||
filter: hasData ? "drop-shadow(0 0 8px rgba(56,189,248,0.6))" : "none"
|
||||
},
|
||||
pressed: { outline: "none" }
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (d) {
|
||||
setTooltipContent(`${d.label}: ${fmtBytes(d.value)}`);
|
||||
} else {
|
||||
setTooltipContent(`${geo.properties.name}: No data`);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTooltipContent("");
|
||||
setTooltipData(d ? d : { label: geo.properties.name, value: 0 });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -52,10 +52,10 @@ export interface Agent {
|
||||
last_seen_at: { human: string; date: string; unix_time: number };
|
||||
}
|
||||
|
||||
export async function getAgents(): Promise<Agent[]> {
|
||||
export async function getAgents(clientSiteUuid?: string): Promise<Agent[]> {
|
||||
try {
|
||||
const API_KEY = getApiKey();
|
||||
const SITE_UUID = getSiteUuid();
|
||||
const SITE_UUID = clientSiteUuid || getSiteUuid();
|
||||
|
||||
// All agent data comes from MongoDB (single source of truth per Rule 13)
|
||||
if (API_KEY && API_KEY.startsWith("sk_db_")) {
|
||||
@@ -68,18 +68,29 @@ export async function getAgents(): Promise<Agent[]> {
|
||||
}
|
||||
const db = mongoose.connection.db;
|
||||
|
||||
const filter: any = {};
|
||||
if (SITE_UUID) {
|
||||
filter.site_uuid = SITE_UUID;
|
||||
}
|
||||
|
||||
// Get all agents from MongoDB summaries collection
|
||||
const agentSummaries = await db.collection('summaries')
|
||||
.aggregate([
|
||||
{ $match: filter },
|
||||
{ $sort: { timestamp: -1 } },
|
||||
{ $group: { _id: '$agent_uuid', lastSeen: { $first: '$timestamp' }, label: { $first: '$agent_label' } } },
|
||||
]).toArray();
|
||||
|
||||
// Check for any flows recorded in the last 12 hours to determine online status
|
||||
const twelveHoursAgo = new Date(Date.now() - 12 * 3600000);
|
||||
const activeAgents = await db.collection('flows').distinct('agent_uuid', {
|
||||
timestamp: { $gte: twelveHoursAgo }
|
||||
});
|
||||
const activeAgentsSet = new Set(activeAgents);
|
||||
|
||||
const agents: Agent[] = agentSummaries.map((item: any, idx: number) => {
|
||||
const lastSeenDate = item.lastSeen ? new Date(item.lastSeen) : null;
|
||||
const nowMs = Date.now();
|
||||
const diffMin = lastSeenDate ? (nowMs - lastSeenDate.getTime()) / 60000 : Infinity;
|
||||
const isOnline = diffMin < 10;
|
||||
const isOnline = activeAgentsSet.has(item._id);
|
||||
const statusHuman = isOnline
|
||||
? 'Online'
|
||||
: lastSeenDate
|
||||
|
||||
+23
-4
@@ -3,7 +3,7 @@
|
||||
export interface ManagedUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: 'SUPER_ADMIN' | 'AGENT_VIEWER';
|
||||
role: 'SUPER_ADMIN' | 'AGENT_VIEWER' | 'SOC_ANALYST' | 'ENGINEER' | 'TENANT_ADMIN' | string;
|
||||
site_uuid: string | null;
|
||||
agent_uuid: string | null;
|
||||
account_name: string | null;
|
||||
@@ -68,6 +68,18 @@ export async function createAdminAgentUser(data: {
|
||||
return json;
|
||||
}
|
||||
|
||||
// POST /api/auth/admin/create-external-user (FormData)
|
||||
export async function createAdminExternalUser(formData: FormData): Promise<{ ok: boolean; message: string; userId: string }> {
|
||||
const res = await fetch('/api/auth/admin/create-external-user', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Failed to create external account');
|
||||
return json;
|
||||
}
|
||||
|
||||
// POST /api/auth/admin/update-agent-user (FormData)
|
||||
export async function updateAdminAgentUser(formData: FormData): Promise<{ ok: boolean; message: string }> {
|
||||
const res = await fetch('/api/auth/admin/update-agent-user', {
|
||||
@@ -139,9 +151,16 @@ export async function getViewAsStatus(): Promise<ViewAsStatus> {
|
||||
|
||||
// Buat headers untuk fetch yang menyertakan X-View-As-Agent jika aktif
|
||||
export function getViewAsHeaders(): Record<string, string> {
|
||||
const token = getViewAsToken();
|
||||
if (!token) return {};
|
||||
return { 'X-View-As-Agent': token };
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = getViewAsToken();
|
||||
if (token) headers['X-View-As-Agent'] = token;
|
||||
const siteUuid = localStorage.getItem('backone_site_uuid') || '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||
headers['x-backone-site-uuid'] = siteUuid;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Resolusi label agent dari daftar users (account_name) atau fallback ke uuid
|
||||
|
||||
+5
-12
@@ -35,19 +35,12 @@ export interface IntelTorDetection { id: number; fetched_at: string; detected_at
|
||||
export interface IntelUnencryptedPassword { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; dst_ip: string; dst_port: number; protocol: string; username: string; download: number; upload: number; severity: string; }
|
||||
export interface IntelVpnDetection { id: number; fetched_at: string; detected_at: string; ip_address: string; mac_address: string; vpn_type: string; remote_ip: string; protocol: string; download: number; upload: number; country: string; confidence: number; }
|
||||
|
||||
import { useTimeFilter } from '@/contexts/TimeFilterContext';
|
||||
import { useCtxFetch } from './api-context-core';
|
||||
|
||||
function useGenericFetch<T>(endpoint: string, limit: number) {
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher<T[]>(`${endpoint}?limit=${limit}`)
|
||||
.then((res) => setData(res || []))
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [endpoint, limit]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
const { timeRange } = useTimeFilter();
|
||||
return useCtxFetch<T[]>(`${endpoint}?limit=${limit}`, timeRange, []);
|
||||
}
|
||||
|
||||
// Network Infrastructure Hooks
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// src/lib/api-category-detail.ts
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Frontend API hook for the BackOne Traffic Categories detail modal.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const API_BASE = '';
|
||||
|
||||
export interface CategoryDetailAppRow {
|
||||
app_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flows: number;
|
||||
agent_uuid: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export interface CategoryDetailDeviceRow {
|
||||
src_ip: string;
|
||||
device_label: string;
|
||||
mac_address: string;
|
||||
manufacturer: string;
|
||||
os_label: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flows: number;
|
||||
agent_uuid: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
export interface CategoryDetailResult {
|
||||
ok: boolean;
|
||||
category: string;
|
||||
apps: CategoryDetailAppRow[];
|
||||
devices: CategoryDetailDeviceRow[];
|
||||
}
|
||||
|
||||
export function useCategoryDetail(
|
||||
category: string | null,
|
||||
timeRange: string = '1d'
|
||||
) {
|
||||
const [data, setData] = useState<{ apps: CategoryDetailAppRow[], devices: CategoryDetailDeviceRow[] }>({ apps: [], devices: [] });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchDetail = useCallback(async () => {
|
||||
if (!category) { setData({ apps: [], devices: [] }); return; }
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ category, timeRange });
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/dashboard/category-detail?${params}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const result: CategoryDetailResult = await response.json();
|
||||
if (result.ok) {
|
||||
setData({ apps: result.apps || [], devices: result.devices || [] });
|
||||
} else {
|
||||
throw new Error('Server error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
setData({ apps: [], devices: [] });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [category, timeRange]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDetail();
|
||||
}, [fetchDetail]);
|
||||
|
||||
return { data, isLoading, error, refetch: fetchDetail };
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import { fetcher } from './api';
|
||||
// Global memory cache to prevent UI reloading flicker when changing pages
|
||||
export const globalCache: Record<string, any> = {};
|
||||
|
||||
// Auto-refresh interval: 5 minutes (matches proxy cron schedule)
|
||||
export const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
// Auto-refresh interval: 1 minute (as requested by user)
|
||||
export const REFRESH_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* Generic hook factory with:
|
||||
|
||||
@@ -364,4 +364,19 @@ export interface SecurityDevice {
|
||||
device_type: string | null;
|
||||
os_label: string | null;
|
||||
manufacturer: string | null;
|
||||
timestamp?: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteIpDetails {
|
||||
ip_address: string;
|
||||
ip_version: number;
|
||||
total_download: number;
|
||||
total_upload: number;
|
||||
last_seen: string | null;
|
||||
first_seen: string | null;
|
||||
protocols: { app_label: string; download: number; upload: number; last_seen: string; first_seen: string }[];
|
||||
domains: { app_label: string; download: number; upload: number; last_seen: string; first_seen: string }[];
|
||||
local_devices: { app_label: string; download: number; upload: number; last_seen: string; first_seen: string }[];
|
||||
flows: FlowStat[];
|
||||
threats: ThreatStat[];
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
const API_BASE = '';
|
||||
|
||||
export type MetadataDetailType =
|
||||
| 'sni_hostname'
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface DnsStat {
|
||||
query_count: number;
|
||||
app_label: string | null;
|
||||
category: string | null;
|
||||
last_seen?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface EventStat {
|
||||
@@ -62,6 +64,7 @@ export interface TlsVersionStat {
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface TlsCipherStat {
|
||||
@@ -70,6 +73,9 @@ export interface TlsCipherStat {
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
timestamp?: string;
|
||||
security_status?: 'Secure' | 'Weak' | 'Vulnerable' | 'Moderate' | 'Unknown';
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TlsSecurityStat {
|
||||
@@ -79,6 +85,7 @@ export interface TlsSecurityStat {
|
||||
download: number;
|
||||
upload: number;
|
||||
total: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface IntelligenceStats {
|
||||
@@ -178,6 +185,7 @@ export interface SslSanStat {
|
||||
upload: number;
|
||||
flows: number;
|
||||
total: number;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ContinentStat {
|
||||
|
||||
+23
-3
@@ -80,18 +80,38 @@ export interface LookupApplicationsResponse {
|
||||
};
|
||||
}
|
||||
|
||||
export function useLookupApplications(page: number = 1, limit: number = 25, search: string = '') {
|
||||
export function useLookupApplications(page: number = 1, limit: number = 25, search: string = '', category: string = '') {
|
||||
const [data, setData] = useState<LookupApplicationsResponse | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetcher<LookupApplicationsResponse>(`/lookup/applications?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`)
|
||||
let endpoint = `/lookup/applications?page=${page}&limit=${limit}&search=${encodeURIComponent(search)}`;
|
||||
if (category) {
|
||||
endpoint += `&category=${encodeURIComponent(category)}`;
|
||||
}
|
||||
fetcher<LookupApplicationsResponse>(endpoint)
|
||||
.then(setData)
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [page, limit, search]);
|
||||
}, [page, limit, search, category]);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
|
||||
export function useLookupCategories() {
|
||||
const [data, setData] = useState<string[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetcher<string[]>(`/lookup/categories`)
|
||||
.then(setData)
|
||||
.catch(setError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, []);
|
||||
|
||||
return { data, error, isLoading };
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"use client";
|
||||
|
||||
import { fetcher } from "./core";
|
||||
import { AgentDetails, DeviceDetails, AppDetails, SecurityDevice } from "../api-detail-types";
|
||||
import { AgentDetails, DeviceDetails, AppDetails, SecurityDevice, RemoteIpDetails } from "../api-detail-types";
|
||||
|
||||
export async function fetchAgentDetails(uuid: string): Promise<AgentDetails> {
|
||||
return fetcher<AgentDetails>(`/agent-details?uuid=${encodeURIComponent(uuid)}`);
|
||||
@@ -17,6 +17,13 @@ export async function fetchDeviceDetails(ip: string, timeRange: string = 'all',
|
||||
return fetcher<DeviceDetails>(`/device-details?${query.join('&')}`);
|
||||
}
|
||||
|
||||
export async function fetchRemoteIpDetails(ip: string, timeRange: string = 'all'): Promise<RemoteIpDetails> {
|
||||
const query = [];
|
||||
if (ip) query.push(`ip=${encodeURIComponent(ip)}`);
|
||||
query.push(`timeRange=${timeRange}`);
|
||||
return fetcher<RemoteIpDetails>(`/remote-ip-details?${query.join('&')}`);
|
||||
}
|
||||
|
||||
export async function fetchAppDetails(label: string, timeRange: string = '1h', agentUuid?: string | null): Promise<AppDetails> {
|
||||
let url = `/app-details?label=${encodeURIComponent(label)}&timeRange=${timeRange}`;
|
||||
if (agentUuid) {
|
||||
|
||||
+154
-1
@@ -70,7 +70,7 @@ export function formatAppLabel(appLabel: string | null | undefined): string {
|
||||
"8080": "Alt HTTP (Web Proxy/Dev)",
|
||||
"8443": "Alt HTTPS (Web Interface)",
|
||||
"9200": "Elasticsearch",
|
||||
"27017": "MongoDB DB",
|
||||
"27017": "Database Server",
|
||||
};
|
||||
|
||||
if (PORT_DESCRIPTIONS[port]) {
|
||||
@@ -128,3 +128,156 @@ export function getAppFavicon(appLabel: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatOSLabel(os: string | null | undefined): string {
|
||||
if (!os || os === "-" || os === "Unknown") return "Unknown";
|
||||
const label = os.toLowerCase();
|
||||
|
||||
if (label.includes("windows")) return "Windows";
|
||||
if (label.includes("mac") || label.includes("darwin") || label.includes("osx")) return "Mac";
|
||||
if (label.includes("android")) return "Android";
|
||||
if (label.includes("ios") || label.includes("iphone") || label.includes("ipad")) return "Apple iOS";
|
||||
if (label.includes("linux") || label.includes("ubuntu") || label.includes("debian") || label.includes("centos")) return "Linux";
|
||||
if (label.includes("tizen")) return "Tizen OS";
|
||||
if (label.includes("embedded") || label.includes("rtos")) return "Embedded OS";
|
||||
|
||||
return "Other";
|
||||
}
|
||||
|
||||
export function formatDeviceTypeLabel(type: string | null | undefined): string {
|
||||
if (!type || type === "-" || type === "Unknown" || type === "Generic Client") return "Unknown";
|
||||
const label = type.toLowerCase();
|
||||
|
||||
if (label.includes("workstation") || label.includes("computer") || label.includes("laptop") || label.includes("desktop") || label.includes("pc")) return "Computer";
|
||||
if (label.includes("mobile") || label.includes("phone")) return "Mobile Phone";
|
||||
if (label.includes("tablet") || label.includes("pad")) return "Tablet";
|
||||
if (label.includes("router") || label.includes("gateway") || label.includes("switch") || label.includes("node") || label.includes("ap") || label.includes("network")) return "Networking";
|
||||
if (label.includes("firewall") || label.includes("security")) return "Firewall/Security";
|
||||
if (label.includes("printer") || label.includes("scanner")) return "Printer/Scanner";
|
||||
if (label.includes("tv") || label.includes("audio") || label.includes("video") || label.includes("dvr") || label.includes("camera") || label.includes("media")) return "TV/DVR/Audio";
|
||||
if (label.includes("database") || label.includes("server")) return "Server";
|
||||
|
||||
return "Other";
|
||||
}
|
||||
|
||||
export function formatTimestampWIB(ts: string | Date | null | undefined): string {
|
||||
if (!ts) return "-";
|
||||
try {
|
||||
return new Date(ts).toLocaleString('id-ID', {
|
||||
timeZone: 'Asia/Jakarta',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).replace(/\./g, ':');
|
||||
} catch {
|
||||
return String(ts);
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateDuration(start: string | Date | null | undefined, end: string | Date | null | undefined): string {
|
||||
if (!start || !end) return "-";
|
||||
try {
|
||||
const s = new Date(start).getTime();
|
||||
const e = new Date(end).getTime();
|
||||
const diff = Math.max(0, e - s);
|
||||
|
||||
const seconds = Math.floor((diff / 1000) % 60);
|
||||
const minutes = Math.floor((diff / (1000 * 60)) % 60);
|
||||
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
|
||||
|
||||
return parts.join(' ');
|
||||
} catch (e) {
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
|
||||
export function getKnownDomainService(domain: string | null | undefined): string | null {
|
||||
if (!domain) return null;
|
||||
const d = domain.toLowerCase();
|
||||
|
||||
// CDN & Cloud Infrastructure
|
||||
if (d.includes("googlevideo.com")) return "YouTube (Google CDN)";
|
||||
if (d.includes("fbcdn.net")) return "Facebook CDN";
|
||||
if (d.includes("akamaihd.net") || d.includes("akamaized.net") || d.includes("akamai.net")) return "Akamai CDN";
|
||||
if (d.includes("cloudfront.net")) return "Amazon CloudFront";
|
||||
if (d.includes("amazonaws.com")) return "Amazon Web Services";
|
||||
if (d.includes("cloudflare.com") || d.includes("cloudflare.net")) return "Cloudflare";
|
||||
if (d.includes("fastly.net")) return "Fastly CDN";
|
||||
if (d.includes("cdn77.org") || d.includes("cdn77.net")) return "CDN77";
|
||||
|
||||
// Tech Giants
|
||||
if (d.includes("apple.com") || d.includes("mzstatic.com")) return "Apple Services";
|
||||
if (d.includes("icloud.com") || d.includes("apple-cloudkit.com")) return "Apple iCloud";
|
||||
if (d.includes("microsoft.com") || d.includes("live.com") || d.includes("office.com")) return "Microsoft Services";
|
||||
if (d.includes("windowsupdate.com")) return "Windows Update";
|
||||
if (d.includes("google.com") || d.includes("googleapis.com") || d.includes("gstatic.com")) return "Google Services";
|
||||
|
||||
// Social & Media
|
||||
if (d.includes("whatsapp.net") || d.includes("whatsapp.com")) return "WhatsApp";
|
||||
if (d.includes("instagram.com")) return "Instagram";
|
||||
if (d.includes("tiktokv.com") || d.includes("tiktokcdn.com") || d.includes("byteoversea.com") || d.includes("tiktok.com")) return "TikTok";
|
||||
if (d.includes("netflix.com") || d.includes("netflix.net") || d.includes("nflxvideo.net") || d.includes("nflxext.com")) return "Netflix";
|
||||
if (d.includes("spotify.com") || d.includes("spotifycdn.com") || d.includes("scdn.co")) return "Spotify";
|
||||
if (d.includes("twitter.com") || d.includes("twimg.com") || d.includes("x.com")) return "X (Twitter)";
|
||||
if (d.includes("zoom.us")) return "Zoom Video";
|
||||
if (d.includes("discord.gg") || d.includes("discord.com") || d.includes("discordapp.net") || d.includes("discordapp.com")) return "Discord";
|
||||
if (d.includes("hystreamer.com") || d.includes("bigolive.tv")) return "Bigo Live";
|
||||
if (d.includes("xnxx-cdn.com") || d.includes("pornhub.com")) return "Adult Content (18+)";
|
||||
if (d.includes("kompas.com") || d.includes("kompas.id")) return "Kompas (Media)";
|
||||
if (d.includes("sfx.ms") || d.includes("onedrive.com") || d.includes("sharepoint.com")) return "Microsoft OneDrive";
|
||||
if (d.includes("symantecliveupdate.com") || d.includes("norton.com")) return "Symantec/Norton Antivirus";
|
||||
|
||||
// E-Commerce & Local (Indonesia)
|
||||
if (d.includes("shopeemobile.com") || d.includes("shopee.co.id")) return "Shopee";
|
||||
if (d.includes("tokopedia.com") || d.includes("tokopedia.net")) return "Tokopedia";
|
||||
if (d.includes("gojek.com") || d.includes("go-jek.com")) return "Gojek";
|
||||
if (d.includes("grab.com")) return "Grab";
|
||||
|
||||
// Gaming
|
||||
if (d.includes("steampowered.com") || d.includes("steamcontent.com")) return "Steam (Gaming)";
|
||||
if (d.includes("epicgames.com")) return "Epic Games";
|
||||
if (d.includes("roblox.com")) return "Roblox";
|
||||
if (d.includes("garena.com")) return "Garena";
|
||||
if (d.includes("hoyoverse.com") || d.includes("mihoyo.com")) return "HoYoverse (Genshin)";
|
||||
if (d.includes("riotgames.com") || d.includes("valorant")) return "Riot Games";
|
||||
if (d.includes("summonerswar") || d.includes("qpyou.cn")) return "Summoners War";
|
||||
// Dynamic Fallback Heuristic
|
||||
const parts = d.split('.');
|
||||
if (parts.length > 1) {
|
||||
let target = parts[parts.length - 2];
|
||||
// Handle double TLDs like .co.id, .com.sg, .ac.uk
|
||||
if (['co', 'com', 'ac', 'go', 'or', 'net', 'edu'].includes(target) && parts.length > 2) {
|
||||
target = parts[parts.length - 3];
|
||||
}
|
||||
// Remove dashes for aesthetic purposes
|
||||
target = target.replace(/-/g, ' ').trim();
|
||||
if (target && target.length > 2) {
|
||||
// Capitalize first letter of each word
|
||||
return target.split(' ').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getActiveStatus(lastSeen: string | Date | null | undefined, thresholdMinutes: number = 5): boolean {
|
||||
if (!lastSeen) return false;
|
||||
try {
|
||||
const last = new Date(lastSeen).getTime();
|
||||
const now = Date.now();
|
||||
// Default: active if seen within the last `thresholdMinutes` minutes
|
||||
return (now - last) <= thresholdMinutes * 60 * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user