feat(source2): lock dark mode, sans-serif typography, and all recent updates
This commit is contained in:
1 parent
dd4c8f6876
commit
9f24b56e97
259 files changed
+15596
-8966
No files matched your search
@@ -102,15 +102,27 @@ export default function AgentsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-white flex items-center gap-3">
|
||||
Agents Inventory
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">Managed DPI probes, remote collectors, and probe infrastructure.</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Agents Inventory</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="agents" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
|
||||
Managed DPI probes, remote collectors, and probe infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="agents" />
|
||||
</div>
|
||||
<HelpTrigger pageId="agents" />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -129,17 +129,27 @@ export default function AppsPage() {
|
||||
];
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Apps
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Apps</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="apps" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
|
||||
Click any application to see which agents and devices are using it.
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="apps" />
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="apps" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
|
||||
@@ -106,18 +106,28 @@ export default function DeviceLabelingPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Device Labeling
|
||||
<Tag className="w-6 h-6 text-primary" />
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Device Labeling</span>
|
||||
<Tag className="w-5 h-5 sm:w-6 sm:h-6 text-primary flex-shrink-0" />
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="device-labeling" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
|
||||
Assign custom names to MAC addresses to identify device owners across all charts and flows.
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="device-labeling" />
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="device-labeling" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-white/5 bg-black/40 backdrop-blur-md">
|
||||
|
||||
@@ -1,250 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { DeviceStat } from "@/lib/api";
|
||||
import { useDevices } from "@/lib/api-with-context";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { useState, useEffect } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { DeviceStat } from "@/lib/api";
|
||||
import { useDevices } from "@/lib/api-with-context";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { Loader2, ChevronRight } from "lucide-react";
|
||||
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const devices = useDevices();
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
||||
|
||||
const {
|
||||
filters, dateFrom, dateTo,
|
||||
handleFilterChange, setDateFrom, setDateTo,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Devices | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const all = devices.data || [];
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "device_type", label: "Device Type", type: "select", value: filters.device_type, options: opts("device_type") },
|
||||
{ id: "os_label", label: "OS", type: "select", value: filters.os_label, options: opts("os_label") },
|
||||
{ id: "manufacturer", label: "Manufacturer", type: "select", value: filters.manufacturer, options: opts("manufacturer") },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const columns: Column<DeviceStat>[] = [
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[13%]",
|
||||
accessor: (row) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "IP Address",
|
||||
className: "w-[12%]",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => setSelectedDevice(row)}
|
||||
className="flex items-center justify-center gap-1 group text-center"
|
||||
title="Click to view device details"
|
||||
>
|
||||
<span className="font-semibold text-blue-400 group-hover:text-blue-300 transition-colors font-mono text-sm">
|
||||
{row.ip_address || "-"}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||
</button>
|
||||
{row.device_label && (
|
||||
<span className="text-[10px] text-slate-400 font-medium mt-0.5 truncate max-w-[120px]" title={row.device_label}>
|
||||
{row.device_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "MAC Address",
|
||||
className: "w-[17%]",
|
||||
accessor: (row) => {
|
||||
const isRouted = row.is_gateway_routed === 1;
|
||||
const hasMan = row.manufacturer && row.manufacturer !== "-" && row.manufacturer !== "Unknown";
|
||||
const info = hasMan ? row.manufacturer : (row.device_type && row.device_type !== "-" && row.device_type !== "Generic Client" ? row.device_type : "");
|
||||
|
||||
return row.mac_address ? (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground w-fit font-mono">
|
||||
{row.mac_address}
|
||||
</code>
|
||||
{(isRouted || info) && (
|
||||
<span className={`text-[10px] font-sans font-medium ${isRouted ? "text-amber-500/90 italic" : "text-slate-400/80"}`}>
|
||||
{isRouted ? "Via Routed Gateway" : ""}
|
||||
{isRouted && info ? ` • ${info}` : (!isRouted ? info : "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Type",
|
||||
accessor: (row) => row.device_type || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: "OS",
|
||||
accessor: (row) => row.os_label || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: "Manufacturer",
|
||||
accessor: (row) => row.manufacturer || "-",
|
||||
className: "w-[14%]"
|
||||
},
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
];
|
||||
|
||||
const filteredData = all.filter((row: any) => {
|
||||
if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false;
|
||||
if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false;
|
||||
if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false;
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : "";
|
||||
if (dateFrom && d < dateFrom) return false;
|
||||
if (dateTo && d > dateTo) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const minTime = all.reduce((min: number, r: any) => {
|
||||
if (r.last_seen) {
|
||||
const time = new Date(r.last_seen).getTime();
|
||||
if (isFinite(time) && time < min) return time;
|
||||
}
|
||||
return min;
|
||||
}, Infinity);
|
||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Devices
|
||||
{devices.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Monitor and manage connected network devices.{" "}
|
||||
<span className="text-slate-500 text-xs">Click an IP to see app usage & flows.</span>
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="devices" />
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
}}
|
||||
showDateRange={true}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
minDate={minDate}
|
||||
onDateChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: "100ms" }}>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-white">Active Devices ({processedData.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={processedData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search devices by IP, MAC..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesSearch = (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.mac_address || "").toLowerCase().includes(q) ||
|
||||
(row.os_label || "").toLowerCase().includes(q) ||
|
||||
(row.device_type || "").toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
return matchesSearch;
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `devices-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "Device Label", "MAC Address", "Device Type", "OS", "Manufacturer", "Download (bytes)", "Upload (bytes)", "Last Seen"],
|
||||
rowSerializer: (row) => [
|
||||
row.ip_address || "",
|
||||
row.device_label || "",
|
||||
row.mac_address || "",
|
||||
row.device_type || "",
|
||||
row.os_label || "",
|
||||
row.manufacturer || "",
|
||||
row.download ?? 0,
|
||||
row.upload ?? 0,
|
||||
row.last_seen ? new Date(row.last_seen).toISOString() : "",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device Detail Modal */}
|
||||
{selectedDevice && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip_address}
|
||||
deviceData={selectedDevice}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
export default function DevicesPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const devices = useDevices();
|
||||
const [selectedDevice, setSelectedDevice] = useState<DeviceStat | null>(null);
|
||||
|
||||
const {
|
||||
filters, dateFrom, dateTo,
|
||||
handleFilterChange, setDateFrom, setDateTo,
|
||||
handleReset, activeCount
|
||||
} = useUniversalFilterState();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
document.title = "Devices | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const all = devices.data || [];
|
||||
|
||||
const opts = (key: string) => {
|
||||
const raw = Array.from(new Set(all.map((r: any) => String(r[key] || "")).filter(Boolean)));
|
||||
return raw.sort();
|
||||
};
|
||||
|
||||
const filterConfigs: FilterConfig[] = [
|
||||
{ id: "device_type", label: "Device Type", type: "select", value: filters.device_type, options: opts("device_type") },
|
||||
{ id: "os_label", label: "OS", type: "select", value: filters.os_label, options: opts("os_label") },
|
||||
{ id: "manufacturer", label: "Manufacturer", type: "select", value: filters.manufacturer, options: opts("manufacturer") },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
|
||||
const columns: Column<DeviceStat>[] = [
|
||||
{
|
||||
header: "Last Seen",
|
||||
className: "w-[13%]",
|
||||
accessor: (row) => <TimestampCell timestamp={row.last_seen} showActiveStatus={true} />
|
||||
},
|
||||
{ header: "#", accessor: (row, i) => i + 1, className: "w-[4%]" },
|
||||
{
|
||||
header: "IP Address",
|
||||
className: "w-[12%]",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => setSelectedDevice(row)}
|
||||
className="flex items-center justify-center gap-1 group text-center"
|
||||
title="Click to view device details"
|
||||
>
|
||||
<span className="font-semibold text-blue-400 group-hover:text-blue-300 transition-colors font-mono text-sm">
|
||||
{row.ip_address || "-"}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-slate-600 group-hover:text-blue-400 transition-colors" />
|
||||
</button>
|
||||
{row.device_label && (
|
||||
<span className="text-[10px] text-slate-400 font-medium mt-0.5 truncate max-w-[120px]" title={row.device_label}>
|
||||
{row.device_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "MAC Address",
|
||||
className: "w-[17%]",
|
||||
accessor: (row) => {
|
||||
const isRouted = row.is_gateway_routed === 1;
|
||||
const hasMan = row.manufacturer && row.manufacturer !== "-" && row.manufacturer !== "Unknown";
|
||||
const info = hasMan ? row.manufacturer : (row.device_type && row.device_type !== "-" && row.device_type !== "Generic Client" ? row.device_type : "");
|
||||
|
||||
return row.mac_address ? (
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
<code className="text-xs bg-secondary/50 px-1 py-0.5 rounded text-muted-foreground w-fit font-mono">
|
||||
{row.mac_address}
|
||||
</code>
|
||||
{(isRouted || info) && (
|
||||
<span className={`text-[10px] font-sans font-medium ${isRouted ? "text-amber-500/90 italic" : "text-slate-400/80"}`}>
|
||||
{isRouted ? "Via Routed Gateway" : ""}
|
||||
{isRouted && info ? ` • ${info}` : (!isRouted ? info : "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Type",
|
||||
accessor: (row) => row.device_type || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: "OS",
|
||||
accessor: (row) => row.os_label || "-",
|
||||
className: "w-[11%]"
|
||||
},
|
||||
{
|
||||
header: "Manufacturer",
|
||||
accessor: (row) => row.manufacturer || "-",
|
||||
className: "w-[14%]"
|
||||
},
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-cyan-400">{fmtBytes(row.download)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-400">{fmtBytes(row.upload)}</span>,
|
||||
className: "w-[9%]",
|
||||
},
|
||||
];
|
||||
|
||||
const filteredData = all.filter((row: any) => {
|
||||
if (filters.device_type && filters.device_type !== "All" && row.device_type !== filters.device_type) return false;
|
||||
if (filters.os_label && filters.os_label !== "All" && row.os_label !== filters.os_label) return false;
|
||||
if (filters.manufacturer && filters.manufacturer !== "All" && row.manufacturer !== filters.manufacturer) return false;
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
const d = row.last_seen ? new Date(row.last_seen).toISOString().slice(0, 10) : "";
|
||||
if (dateFrom && d < dateFrom) return false;
|
||||
if (dateTo && d > dateTo) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
let processedData = [...filteredData];
|
||||
const sortUpload = filters.sort_upload || "All";
|
||||
const sortDownload = filters.sort_download || "All";
|
||||
|
||||
if (sortUpload !== "All") {
|
||||
processedData.sort((a, b) => sortUpload === "Descending" ? b.upload - a.upload : a.upload - b.upload);
|
||||
} else if (sortDownload !== "All") {
|
||||
processedData.sort((a, b) => sortDownload === "Descending" ? b.download - a.download : a.download - b.download);
|
||||
}
|
||||
|
||||
const minTime = all.reduce((min: number, r: any) => {
|
||||
if (r.last_seen) {
|
||||
const time = new Date(r.last_seen).getTime();
|
||||
if (isFinite(time) && time < min) return time;
|
||||
}
|
||||
return min;
|
||||
}, Infinity);
|
||||
const minDate = minTime !== Infinity ? new Date(minTime).toISOString().slice(0, 10) : "2024-01-01";
|
||||
|
||||
return (
|
||||
<div className="space-y-6 sm:space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<span>Devices</span>
|
||||
{devices.isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
|
||||
Monitor and manage connected network devices.{" "}
|
||||
<span className="text-slate-500 text-[10px] sm:text-xs block sm:inline mt-0.5 sm:mt-0">Click an IP to see app usage & flows.</span>
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="devices" className="flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
filters={filterConfigs}
|
||||
onChange={(id, val) => {
|
||||
if (id === 'sort_download') {
|
||||
handleFilterChange('sort_download', val);
|
||||
handleFilterChange('sort_upload', 'All');
|
||||
} else if (id === 'sort_upload') {
|
||||
handleFilterChange('sort_upload', val);
|
||||
handleFilterChange('sort_download', 'All');
|
||||
} else {
|
||||
handleFilterChange(id, val);
|
||||
}
|
||||
}}
|
||||
showDateRange={true}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
minDate={minDate}
|
||||
onDateChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
|
||||
activeFilterCount={activeCount}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 animate-slide-up" style={{ animationDelay: "100ms" }}>
|
||||
<CardHeader className="border-b border-border/50 pb-4 mb-4">
|
||||
<CardTitle className="text-white">Active Devices ({processedData.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={processedData}
|
||||
columns={columns}
|
||||
searchPlaceholder="Search devices by IP, MAC..."
|
||||
searchFilter={(row, query) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const matchesSearch = (
|
||||
(row.ip_address || "").toLowerCase().includes(q) ||
|
||||
(row.mac_address || "").toLowerCase().includes(q) ||
|
||||
(row.os_label || "").toLowerCase().includes(q) ||
|
||||
(row.device_type || "").toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
return matchesSearch;
|
||||
}}
|
||||
csvExport={{
|
||||
filename: `devices-${new Date().toISOString().slice(0,10)}.csv`,
|
||||
headers: ["IP Address", "Device Label", "MAC Address", "Device Type", "OS", "Manufacturer", "Download (bytes)", "Upload (bytes)", "Last Seen"],
|
||||
rowSerializer: (row) => [
|
||||
row.ip_address || "",
|
||||
row.device_label || "",
|
||||
row.mac_address || "",
|
||||
row.device_type || "",
|
||||
row.os_label || "",
|
||||
row.manufacturer || "",
|
||||
row.download ?? 0,
|
||||
row.upload ?? 0,
|
||||
row.last_seen ? new Date(row.last_seen).toISOString() : "",
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Device Detail Modal */}
|
||||
{selectedDevice && (
|
||||
<DeviceDetailModal
|
||||
ip={selectedDevice.ip_address}
|
||||
deviceData={selectedDevice}
|
||||
onClose={() => setSelectedDevice(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -97,12 +97,24 @@ export default function DNSPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
DNS
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<HelpTrigger pageId="dns" />
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>DNS</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="dns" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="dns" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
|
||||
@@ -47,15 +47,27 @@ export default function DpiAnalyticsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">DPI MetaData</h1>
|
||||
<p className="text-slate-400 mt-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white tracking-tight truncate">
|
||||
DPI MetaData
|
||||
</h1>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="dpi-analytics" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">
|
||||
Deep Packet Inspection metadata and fingerprinting.{" "}
|
||||
<span className="text-blue-400 text-sm">Click any row to view device-level detail.</span>
|
||||
<span className="text-blue-400 text-[10px] sm:text-xs block sm:inline mt-0.5 sm:mt-0">Click any row to view device-level detail.</span>
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="dpi-analytics" />
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="dpi-analytics" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DpiAnalyticsTabs
|
||||
|
||||
@@ -158,12 +158,24 @@ function EventsContent() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Network Events
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<HelpTrigger pageId="events" />
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Network Events</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="events" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="events" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
@@ -218,6 +230,7 @@ function EventsContent() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function EventsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center p-12"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>}>
|
||||
@@ -225,4 +238,3 @@ export default function EventsPage() {
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
export { EventsContent };
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { explainAppOrPort } from "./explainFlow";
|
||||
|
||||
export function getFlowColumns(onSelectIp: (ip: string) => void): Column<any>[] {
|
||||
return [
|
||||
{
|
||||
header: "Last Seen",
|
||||
accessor: (row) => (
|
||||
<TimestampCell
|
||||
timestamp={row.last_seen}
|
||||
firstSeen={row.first_seen}
|
||||
showActiveStatus={true}
|
||||
/>
|
||||
),
|
||||
className: "w-[14%] text-center",
|
||||
tooltip: (row) => new Date(row.last_seen).toLocaleString()
|
||||
},
|
||||
{
|
||||
header: "#",
|
||||
accessor: (row, i) => i + 1,
|
||||
className: "w-[4%] text-center"
|
||||
},
|
||||
{
|
||||
header: "Flow ID",
|
||||
accessor: (row) => row.flow_id,
|
||||
className: "w-[11%] text-center font-mono text-xs truncate"
|
||||
},
|
||||
{
|
||||
header: "Src IP",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<button
|
||||
onClick={() => onSelectIp(row.src_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full"
|
||||
>
|
||||
{row.src_ip}
|
||||
</button>
|
||||
{row.src_label && (
|
||||
<span className="text-[10px] text-cyan-400 font-semibold tracking-wide mt-0.5 truncate w-full block">
|
||||
{row.src_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
className: "w-[11%] text-center",
|
||||
tooltip: (row) => row.src_label ? `${row.src_ip} (${row.src_label})` : row.src_ip
|
||||
},
|
||||
{
|
||||
header: "Dst IP",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => onSelectIp(row.dst_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full"
|
||||
>
|
||||
{row.dst_ip}
|
||||
</button>
|
||||
<IpDetails ip={row.dst_ip} />
|
||||
</div>
|
||||
),
|
||||
className: "w-[13%] text-center",
|
||||
tooltip: (row) => row.dst_ip
|
||||
},
|
||||
{
|
||||
header: "Dst Port",
|
||||
accessor: (row) => row.dst_port,
|
||||
className: "w-[7%] text-center font-mono text-xs"
|
||||
},
|
||||
{
|
||||
header: "Protocol",
|
||||
accessor: (row) => row.protocol,
|
||||
className: "w-[7%] text-center text-xs"
|
||||
},
|
||||
{
|
||||
header: "App / Domain",
|
||||
accessor: (row) => {
|
||||
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="font-semibold text-white truncate max-w-[200px] text-center" title={exp.main}>
|
||||
{exp.main}
|
||||
</span>
|
||||
{exp.sub && (
|
||||
<span className="text-[10px] text-slate-400 italic text-center truncate max-w-[200px]">
|
||||
{exp.sub}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: "w-[18%] text-center",
|
||||
tooltip: (row) => {
|
||||
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
|
||||
return exp.sub ? `${exp.main} (${exp.sub})` : exp.main;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>,
|
||||
className: "w-[8%] text-center",
|
||||
tooltip: (row) => `${fmtBytes(row.bytes_download)} (${(row.bytes_download || 0).toLocaleString()} bytes)`
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span>,
|
||||
className: "w-[7%] text-center",
|
||||
tooltip: (row) => `${fmtBytes(row.bytes_upload)} (${(row.bytes_upload || 0).toLocaleString()} bytes)`
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { TimestampCell } from "@/components/ui/TimestampCell";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useFlows, useFlowsOptions } from "@/lib/api-with-context";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { Loader2, Download, Search } from "lucide-react";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
import { DeviceDetailModal } from "@/components/ui/DeviceDetailModal";
|
||||
import { UniversalFilters, useUniversalFilterState, FilterConfig } from "@/components/ui/UniversalFilters";
|
||||
import { explainAppOrPort } from "./explainFlow";
|
||||
import { downloadCsv } from "@/components/ui/DataTableCsvHelper";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import { getFlowColumns } from "./flowColumns";
|
||||
|
||||
export default function FlowsPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
@@ -53,29 +51,33 @@ export default function FlowsPage() {
|
||||
document.title = "Flows | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
const columns = useMemo(() => getFlowColumns(setSelectedIp), []);
|
||||
|
||||
const opts = (key: string) => {
|
||||
if (!optionsData) return [];
|
||||
const mapping: Record<string, string[]> = {
|
||||
protocol: optionsData.protocols, src_ip: optionsData.srcIps, dst_ip: optionsData.dstIps,
|
||||
dst_port: optionsData.dstPorts, app_label: optionsData.apps, domain: optionsData.domains
|
||||
const filterConfigs: FilterConfig[] = useMemo(() => {
|
||||
const opts = (key: string) => {
|
||||
if (!optionsData) return [];
|
||||
const mapping: Record<string, string[]> = {
|
||||
protocol: optionsData.protocols, src_ip: optionsData.srcIps, dst_ip: optionsData.dstIps,
|
||||
dst_port: optionsData.dstPorts, app_label: optionsData.apps, domain: optionsData.domains
|
||||
};
|
||||
return mapping[key] || [];
|
||||
};
|
||||
return mapping[key] || [];
|
||||
};
|
||||
|
||||
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"] },
|
||||
];
|
||||
return [
|
||||
{ id: "protocol", label: "Protocol", type: "select", value: filters.protocol, options: opts("protocol"), formatter: formatAppLabel },
|
||||
{ id: "src_ip", label: "Source IP", type: "select", value: filters.src_ip, options: opts("src_ip") },
|
||||
{ id: "dst_ip", label: "Dest IP", type: "select", value: filters.dst_ip, options: opts("dst_ip") },
|
||||
{ id: "dst_port", label: "Dest Port", type: "select", value: filters.dst_port, options: opts("dst_port") },
|
||||
{ id: "app", label: "App", type: "select", value: filters.app, options: opts("app_label"), formatter: formatAppLabel },
|
||||
{ id: "domain", label: "Domain", type: "select", value: filters.domain, options: opts("domain") },
|
||||
{ id: "sort_download", label: "Sort Download", type: "select", value: filters.sort_download, options: ["Descending", "Ascending"] },
|
||||
{ id: "sort_upload", label: "Sort Upload", type: "select", value: filters.sort_upload, options: ["Descending", "Ascending"] },
|
||||
];
|
||||
}, [optionsData, filters]);
|
||||
|
||||
const minDate = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
const minDate = useMemo(() => {
|
||||
return new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString().slice(0, 10);
|
||||
}, []);
|
||||
|
||||
const handleExport = () => {
|
||||
const headers = ["Flow ID", "Src IP", "Dst IP", "Dst Port", "Protocol", "App / Domain", "Download (bytes)", "Upload (bytes)"];
|
||||
@@ -83,120 +85,31 @@ export default function FlowsPage() {
|
||||
downloadCsv(`flows-${new Date().toISOString().slice(0,10)}.csv`, rows);
|
||||
};
|
||||
|
||||
const columns: Column<any>[] = [
|
||||
{
|
||||
header: "Last Seen",
|
||||
accessor: (row) => (
|
||||
<TimestampCell
|
||||
timestamp={row.last_seen}
|
||||
firstSeen={row.first_seen}
|
||||
showActiveStatus={true}
|
||||
/>
|
||||
),
|
||||
className: "w-[14%] text-center",
|
||||
tooltip: (row) => new Date(row.last_seen).toLocaleString()
|
||||
},
|
||||
{
|
||||
header: "#",
|
||||
accessor: (row, i) => i + 1,
|
||||
className: "w-[4%] text-center"
|
||||
},
|
||||
{
|
||||
header: "Flow ID",
|
||||
accessor: (row) => row.flow_id,
|
||||
className: "w-[11%] text-center font-mono text-xs truncate"
|
||||
},
|
||||
{
|
||||
header: "Src IP",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.src_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full"
|
||||
>
|
||||
{row.src_ip}
|
||||
</button>
|
||||
{row.src_label && (
|
||||
<span className="text-[10px] text-cyan-400 font-semibold tracking-wide mt-0.5 truncate w-full block">
|
||||
{row.src_label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
className: "w-[11%] text-center",
|
||||
tooltip: (row) => row.src_label ? `${row.src_ip} (${row.src_label})` : row.src_ip
|
||||
},
|
||||
{
|
||||
header: "Dst IP",
|
||||
accessor: (row) => (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<button
|
||||
onClick={() => setSelectedIp(row.dst_ip)}
|
||||
className="text-blue-400 hover:text-blue-300 hover:underline font-mono text-center focus:outline-none truncate w-full"
|
||||
>
|
||||
{row.dst_ip}
|
||||
</button>
|
||||
<IpDetails ip={row.dst_ip} />
|
||||
</div>
|
||||
),
|
||||
className: "w-[13%] text-center",
|
||||
tooltip: (row) => row.dst_ip
|
||||
},
|
||||
{
|
||||
header: "Dst Port",
|
||||
accessor: (row) => row.dst_port,
|
||||
className: "w-[7%] text-center font-mono text-xs"
|
||||
},
|
||||
{
|
||||
header: "Protocol",
|
||||
accessor: (row) => row.protocol,
|
||||
className: "w-[7%] text-center text-xs"
|
||||
},
|
||||
{
|
||||
header: "App / Domain",
|
||||
accessor: (row) => {
|
||||
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="font-semibold text-white truncate max-w-[200px] text-center" title={exp.main}>
|
||||
{exp.main}
|
||||
</span>
|
||||
{exp.sub && (
|
||||
<span className="text-[10px] text-slate-400 italic text-center truncate max-w-[200px]">
|
||||
{exp.sub}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
className: "w-[18%] text-center",
|
||||
tooltip: (row) => {
|
||||
const exp = explainAppOrPort(row.app_label, row.domain, row.dst_port);
|
||||
return exp.sub ? `${exp.main} (${exp.sub})` : exp.main;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Download",
|
||||
accessor: (row) => <span className="text-blue-400">{fmtBytes(row.bytes_download)}</span>,
|
||||
className: "w-[8%] text-center",
|
||||
tooltip: (row) => `${fmtBytes(row.bytes_download)} (${(row.bytes_download || 0).toLocaleString()} bytes)`
|
||||
},
|
||||
{
|
||||
header: "Upload",
|
||||
accessor: (row) => <span className="text-green-500">{fmtBytes(row.bytes_upload)}</span>,
|
||||
className: "w-[7%] text-center",
|
||||
tooltip: (row) => `${fmtBytes(row.bytes_upload)} (${(row.bytes_upload || 0).toLocaleString()} bytes)`
|
||||
}
|
||||
];
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Flows
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<HelpTrigger pageId="flows" />
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Flows</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="flows" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">
|
||||
Real-time packet inspection and active network connection flows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="flows" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
|
||||
@@ -104,15 +104,27 @@ export default function GeographyPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Geo Traffic
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Visualize data flow and activity across the globe.</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Geo Traffic</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="geography" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
|
||||
Visualize data flow and activity across the globe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="geography" />
|
||||
</div>
|
||||
<HelpTrigger pageId="geography" />
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
|
||||
@@ -96,12 +96,24 @@ export default function IntelligencePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Threat Intelligence
|
||||
{loadingStats && <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />}
|
||||
</h2>
|
||||
<HelpTrigger pageId="intelligence" />
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Threat Intelligence</span>
|
||||
{loadingStats && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="intelligence" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="intelligence" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { DashboardLayout } from "@/components/layout/DashboardLayout";
|
||||
import { TimeFilterProvider } from "@/contexts/TimeFilterContext";
|
||||
import { TenantConfigProvider } from "@/contexts/TenantConfigContext";
|
||||
@@ -8,10 +10,10 @@ export default function AppLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<TimeFilterProvider>
|
||||
<TenantConfigProvider>
|
||||
<TenantConfigProvider>
|
||||
<TimeFilterProvider>
|
||||
<DashboardLayout>{children}</DashboardLayout>
|
||||
</TenantConfigProvider>
|
||||
</TimeFilterProvider>
|
||||
</TimeFilterProvider>
|
||||
</TenantConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -21,16 +21,26 @@ export default function LookupPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight flex items-center gap-3">
|
||||
App Lookup
|
||||
</h1>
|
||||
<p className="text-slate-400 mt-2">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white tracking-tight truncate">
|
||||
App Lookup
|
||||
</h1>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="lookup" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">
|
||||
Search {branding.name === "BackOne" ? "BackOne's" : `${branding.name}'s`} extensive classification database of over 2,500+ apps, protocols, and services, and manage your blacklist configuration.
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="lookup" />
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="lookup" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
|
||||
@@ -1,253 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Tabs, Tab } from "@/components/ui/Tabs";
|
||||
import { DataTable, Column } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import {
|
||||
useRegions, useCities, useVlans, useInterfaces,
|
||||
useIpVersions, useRemoteIps, useMacBandwidth,
|
||||
useFlowTypes, useFlowOrigins
|
||||
} from "@/lib/api-advanced";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
|
||||
export default function NetworkInfrastructurePage() {
|
||||
const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Network Topology | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
// Routing & Geography Tab
|
||||
const { data: regions, isLoading: loadingRegions } = useRegions();
|
||||
const { data: cities, isLoading: loadingCities } = useCities();
|
||||
|
||||
// Interfaces & VLANs Tab
|
||||
const { data: interfaces, isLoading: loadingInterfaces } = useInterfaces();
|
||||
const { data: vlans, isLoading: loadingVlans } = useVlans();
|
||||
|
||||
// IP & MAC Tab
|
||||
const { data: remoteIps, isLoading: loadingRemoteIps } = useRemoteIps();
|
||||
const { data: ipVersions, isLoading: loadingIpVersions } = useIpVersions();
|
||||
const { data: macBandwidth, isLoading: loadingMacBandwidth } = useMacBandwidth();
|
||||
|
||||
// Flow Profiles Tab
|
||||
const { data: flowTypes, isLoading: loadingFlowTypes } = useFlowTypes();
|
||||
const { data: flowOrigins, isLoading: loadingFlowOrigins } = useFlowOrigins();
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0 || !bytes) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white tracking-tight">Network Topology</h1>
|
||||
<p className="text-slate-400 mt-2">Physical, logical, and geographic network topologies.</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="network-infrastructure" />
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
<Tab label="Routing & Geography">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Regions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={regions}
|
||||
isLoading={loadingRegions}
|
||||
columns={[
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Cities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={cities}
|
||||
isLoading={loadingCities}
|
||||
columns={[
|
||||
{ header: "City", accessor: (row: any) => <span title={row.city_name}>{row.city_name}</span> },
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="Interfaces & VLANs">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Network Interfaces</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={interfaces}
|
||||
isLoading={loadingInterfaces}
|
||||
columns={[
|
||||
{ header: "Interface", accessor: (row: any) => row.iface_name },
|
||||
{ header: "Role", accessor: (row: any) => row.iface_role },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>VLANs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={vlans}
|
||||
isLoading={loadingVlans}
|
||||
columns={[
|
||||
{ header: "VLAN ID", accessor: (row: any) => row.vlan_id },
|
||||
{ header: "Label", accessor: (row: any) => row.vlan_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="IP & MAC Properties">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Remote IPs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={remoteIps}
|
||||
isLoading={loadingRemoteIps}
|
||||
onRowClick={(row) => setSelectedRemoteIp(row.remote_ip)}
|
||||
columns={[
|
||||
{
|
||||
header: "IP Address",
|
||||
accessor: (row: any) => row.remote_ip ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{row.remote_ip}</span>
|
||||
<IpDetails ip={row.remote_ip} />
|
||||
</div>
|
||||
) : "—"
|
||||
},
|
||||
{ header: "IP Version", accessor: (row) => `IPv${row.ip_version}` },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IP Versions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={ipVersions}
|
||||
isLoading={loadingIpVersions}
|
||||
columns={[
|
||||
{ header: "Version", accessor: (row: any) => row.ip_version_label },
|
||||
{ header: "Total Traffic", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MAC Bandwidth</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={macBandwidth}
|
||||
isLoading={loadingMacBandwidth}
|
||||
columns={[
|
||||
{ header: "MAC Address", accessor: (row: any) => <span title={row.mac_address}>{row.mac_address}</span> },
|
||||
{ header: "Manufacturer", accessor: (row: any) => <span title={row.manufacturer}>{row.manufacturer}</span> },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="Flow Profiles">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flow Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={flowTypes}
|
||||
isLoading={loadingFlowTypes}
|
||||
columns={[
|
||||
{ header: "Flow Type", accessor: (row: any) => row.flow_type_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flow Origins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={flowOrigins}
|
||||
isLoading={loadingFlowOrigins}
|
||||
columns={[
|
||||
{ header: "Origin", accessor: (row: any) => row.flow_origin_label },
|
||||
{ header: "Download", accessor: (row) => formatBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => formatBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => formatBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{selectedRemoteIp && (
|
||||
<RemoteIpDetailModal
|
||||
ip={selectedRemoteIp}
|
||||
onClose={() => setSelectedRemoteIp(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Tabs, Tab } from "@/components/ui/Tabs";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import {
|
||||
useVlans, useInterfaces,
|
||||
useIpVersions, useRemoteIps, useMacBandwidth,
|
||||
useFlowTypes, useFlowOrigins
|
||||
} from "@/lib/api-advanced";
|
||||
import { IpDetails } from "@/components/ui/IpDetails";
|
||||
import { RemoteIpDetailModal } from "@/components/ui/RemoteIpDetailModal";
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import { RoutingGeoTab } from "@/components/network/RoutingGeoTab";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
export default function NetworkInfrastructurePage() {
|
||||
const [selectedRemoteIp, setSelectedRemoteIp] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Network Topology | BackOne - Deep Package Inspection";
|
||||
}, []);
|
||||
|
||||
const { data: interfaces, isLoading: loadingInterfaces } = useInterfaces();
|
||||
const { data: vlans, isLoading: loadingVlans } = useVlans();
|
||||
|
||||
const { data: remoteIps, isLoading: loadingRemoteIps } = useRemoteIps();
|
||||
const { data: ipVersions, isLoading: loadingIpVersions } = useIpVersions();
|
||||
const { data: macBandwidth, isLoading: loadingMacBandwidth } = useMacBandwidth();
|
||||
|
||||
const { data: flowTypes, isLoading: loadingFlowTypes } = useFlowTypes();
|
||||
const { data: flowOrigins, isLoading: loadingFlowOrigins } = useFlowOrigins();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-white tracking-tight truncate">Network Topology</h1>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="network-infrastructure" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">Physical, logical, and geographic network topologies.</p>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="network-infrastructure" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs>
|
||||
<Tab label="Routing & Geography">
|
||||
<RoutingGeoTab />
|
||||
</Tab>
|
||||
|
||||
<Tab label="Interfaces & VLANs">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Network Interfaces</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={interfaces}
|
||||
isLoading={loadingInterfaces}
|
||||
columns={[
|
||||
{ header: "Interface", accessor: (row: any) => row.iface_name },
|
||||
{ header: "Role", accessor: (row: any) => row.iface_role },
|
||||
{ header: "Download", accessor: (row) => fmtBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>VLANs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={vlans}
|
||||
isLoading={loadingVlans}
|
||||
columns={[
|
||||
{ header: "VLAN ID", accessor: (row: any) => row.vlan_id },
|
||||
{ header: "Label", accessor: (row: any) => row.vlan_label },
|
||||
{ header: "Download", accessor: (row) => fmtBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="IP & MAC Properties">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="xl:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Remote IPs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={remoteIps}
|
||||
isLoading={loadingRemoteIps}
|
||||
onRowClick={(row) => setSelectedRemoteIp(row.remote_ip)}
|
||||
columns={[
|
||||
{
|
||||
header: "IP Address",
|
||||
accessor: (row: any) => row.remote_ip ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs text-blue-400">{row.remote_ip}</span>
|
||||
<IpDetails ip={row.remote_ip} />
|
||||
</div>
|
||||
) : "—"
|
||||
},
|
||||
{ header: "IP Version", accessor: (row) => `IPv${row.ip_version}` },
|
||||
{ header: "Download", accessor: (row) => fmtBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>IP Versions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={ipVersions}
|
||||
isLoading={loadingIpVersions}
|
||||
columns={[
|
||||
{ header: "Version", accessor: (row: any) => row.ip_version_label },
|
||||
{ header: "Total Traffic", accessor: (row) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MAC Bandwidth</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={macBandwidth}
|
||||
isLoading={loadingMacBandwidth}
|
||||
columns={[
|
||||
{ header: "MAC Address", accessor: (row: any) => <span title={row.mac_address}>{row.mac_address}</span> },
|
||||
{ header: "Manufacturer", accessor: (row: any) => <span title={row.manufacturer}>{row.manufacturer}</span> },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
<Tab label="Flow Profiles">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flow Types</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={flowTypes}
|
||||
isLoading={loadingFlowTypes}
|
||||
columns={[
|
||||
{ header: "Flow Type", accessor: (row: any) => row.flow_type_label },
|
||||
{ header: "Download", accessor: (row) => fmtBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Flow Origins</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={flowOrigins}
|
||||
isLoading={loadingFlowOrigins}
|
||||
columns={[
|
||||
{ header: "Origin", accessor: (row: any) => row.flow_origin_label },
|
||||
{ header: "Download", accessor: (row) => fmtBytes(row.download) },
|
||||
{ header: "Upload", accessor: (row) => fmtBytes(row.upload) },
|
||||
{ header: "Total", accessor: (row) => fmtBytes(row.total) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
{selectedRemoteIp && (
|
||||
<RemoteIpDetailModal
|
||||
ip={selectedRemoteIp}
|
||||
onClose={() => setSelectedRemoteIp(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,15 +84,25 @@ export default function NetworkIntelligencePage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Traffic Categories
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Deep packet inspection analytics and geography.</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Traffic Categories</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="network-intelligence" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">Deep packet inspection analytics and geography.</p>
|
||||
</div>
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="network-intelligence" />
|
||||
</div>
|
||||
<HelpTrigger pageId="network-intelligence" />
|
||||
</div>
|
||||
|
||||
<UniversalFilters
|
||||
|
||||
+107
-23
@@ -2,8 +2,9 @@
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols, useCountries } from "@/lib/api-with-context";
|
||||
import { GlobeMap } from "@/components/ui/GlobeMap";
|
||||
import { useDashboardSummary, useTimeline, useTopApps, useTopProtocols, useCities, useAgentLocations } from "@/lib/api-with-context";
|
||||
// import { GlobeMap } from "@/components/ui/GlobeMap";
|
||||
import { FlatWorldMap } from "@/components/ui/FlatWorldMap";
|
||||
import { getOriginLocation, getDestinationLocation } from "@/lib/geoUtils";
|
||||
import { KPICards } from "@/components/dashboard/KPICards";
|
||||
import { TopWidgets } from "@/components/dashboard/TopWidgets";
|
||||
@@ -15,7 +16,8 @@ export default function SummaryPage() {
|
||||
const timeline = useTimeline(24);
|
||||
const rawTopApps = useTopApps(7);
|
||||
const rawTopProtocols = useTopProtocols();
|
||||
const countries = useCountries(0); // Load all countries for complete globemap (Requirement 16)
|
||||
const cities = useCities(); // Load all cities for complete map
|
||||
const locations = useAgentLocations(); // Load all agent locations for site
|
||||
|
||||
const { topProtocols, topApps } = useMemo(() => {
|
||||
const rawProtos = rawTopProtocols.data || [];
|
||||
@@ -35,26 +37,97 @@ export default function SummaryPage() {
|
||||
upload: p.upload || 0
|
||||
}));
|
||||
|
||||
let mergedApps = [...appsData.filter(a => a.app_label !== 'Unknown'), ...convertedApps];
|
||||
const appMap = new Map();
|
||||
|
||||
// 1. Group appsData, filtering out 'Unknown'
|
||||
appsData.forEach(app => {
|
||||
const label = app.app_label;
|
||||
if (label && label !== 'Unknown') {
|
||||
appMap.set(label, {
|
||||
app_label: label,
|
||||
download: app.download || 0,
|
||||
upload: app.upload || 0
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Group convertedApps (non-standard protocols), summing values to avoid duplication
|
||||
convertedApps.forEach(app => {
|
||||
const label = app.app_label;
|
||||
if (label && label !== 'Unknown') {
|
||||
if (appMap.has(label)) {
|
||||
const existing = appMap.get(label);
|
||||
existing.download += app.download || 0;
|
||||
existing.upload += app.upload || 0;
|
||||
} else {
|
||||
appMap.set(label, {
|
||||
app_label: label,
|
||||
download: app.download || 0,
|
||||
upload: app.upload || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mergedApps = Array.from(appMap.values());
|
||||
mergedApps.sort((a, b) => (b.download || 0) - (a.download || 0));
|
||||
mergedApps = mergedApps.slice(0, 7);
|
||||
|
||||
// If mergedApps is empty but appsData is not, fallback to appsData
|
||||
const finalAppsData = mergedApps.length > 0 ? mergedApps : appsData;
|
||||
|
||||
return {
|
||||
topProtocols: { ...rawTopProtocols, data: actualProtocols },
|
||||
topApps: { ...rawTopApps, data: mergedApps.length > 0 ? mergedApps : appsData }
|
||||
topApps: { ...rawTopApps, data: finalAppsData }
|
||||
};
|
||||
}, [rawTopProtocols.data, rawTopApps.data, rawTopProtocols.isLoading, rawTopApps.isLoading]);
|
||||
|
||||
const mapData = useMemo(() => {
|
||||
if (!countries.data) return [];
|
||||
if (!cities.data) return [];
|
||||
|
||||
const origin = getOriginLocation();
|
||||
// Find active agent location (serial 8A-V3-PB-85 for site SIAB) dynamically from DB
|
||||
const activeLocation = locations.data?.find((loc: any) =>
|
||||
loc.site_uuid === "6681452d_9cae_4ff4_8ae8_0d504774265e" && loc.agent_uuid === "8A-V3-PB-85"
|
||||
) || locations.data?.[0];
|
||||
|
||||
const origin = {
|
||||
lat: activeLocation?.latitude || -6.2253265,
|
||||
lng: activeLocation?.longitude || 106.8061484,
|
||||
label: activeLocation?.label || "IFG LT.18 Agent HQ"
|
||||
};
|
||||
|
||||
const CITY_COORDS: Record<string, { lat: number; lng: number }> = {
|
||||
"Jakarta": { lat: -6.2088, lng: 106.8456 },
|
||||
"Jakarta (BackOne Intranet)": { lat: -6.2150, lng: 106.8150 }, // Local LAN is physically in Jakarta, offset by 2km to draw local flow lines
|
||||
"Bandung": { lat: -6.9147, lng: 107.6098 },
|
||||
"Surabaya": { lat: -7.2504, lng: 112.7688 },
|
||||
"Semarang": { lat: -6.9667, lng: 110.4167 },
|
||||
"Tangerang (CPI Balaraja)": { lat: -6.1915, lng: 106.4526 },
|
||||
"Singapore": { lat: 1.3521, lng: 103.8198 },
|
||||
"Mountain View": { lat: 37.3861, lng: -122.0839 },
|
||||
"Richmond": { lat: 37.5407, lng: -77.4360 },
|
||||
"Chiyoda": { lat: 35.6940, lng: 139.7537 },
|
||||
"Sydney": { lat: -33.8688, lng: 151.2093 }
|
||||
};
|
||||
|
||||
return cities.data.reduce((acc: any[], row) => {
|
||||
const cityName = row.city_name || "Unknown City";
|
||||
const coord = CITY_COORDS[cityName] || CITY_COORDS[row.country_name];
|
||||
|
||||
let dest = null;
|
||||
if (coord) {
|
||||
dest = {
|
||||
lat: coord.lat,
|
||||
lng: coord.lng,
|
||||
label: cityName
|
||||
};
|
||||
} else {
|
||||
const fallbackDest = getDestinationLocation(row.country_code || '', row.country_name);
|
||||
if (fallbackDest) {
|
||||
dest = fallbackDest;
|
||||
}
|
||||
}
|
||||
|
||||
return countries.data.reduce((acc: any[], row) => {
|
||||
// Skip local-to-local if origin is Indonesia and destination is Indonesia
|
||||
if (row.country_code === "ID" && origin.label.includes("Indonesia")) return acc;
|
||||
|
||||
const dest = getDestinationLocation(row.country_code, row.country_name);
|
||||
if (dest) {
|
||||
acc.push({
|
||||
startLat: origin.lat,
|
||||
@@ -71,7 +144,7 @@ export default function SummaryPage() {
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, [countries.data]);
|
||||
}, [cities.data, locations.data]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -84,15 +157,25 @@ export default function SummaryPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Overview Dashboard
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Real-time network traffic and intelligence summary.</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Overview Dashboard</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="overview" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">Real-time network traffic and intelligence summary.</p>
|
||||
</div>
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="overview" />
|
||||
</div>
|
||||
<HelpTrigger pageId="overview" />
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
@@ -100,8 +183,9 @@ export default function SummaryPage() {
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-7 animate-slide-up" style={{ animationDelay: '300ms' }}>
|
||||
{/* Global Connections Map */}
|
||||
<div className="col-span-4 relative flex items-center justify-center">
|
||||
<GlobeMap data={mapData} />
|
||||
<div className="col-span-1 lg:col-span-4 relative flex items-center justify-center w-full min-w-0">
|
||||
{/* <GlobeMap data={mapData} /> */}
|
||||
<FlatWorldMap data={mapData} />
|
||||
</div>
|
||||
|
||||
{/* Top Widgets (Apps & Protocols) */}
|
||||
|
||||
@@ -53,10 +53,10 @@ export default function SecurityAuditPage() {
|
||||
const safeCount = filteredDevices.filter((d) => d.risk_level === "Safe").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white tracking-tight">Security & Encryption Audit</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
<div className="space-y-6 pb-10">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-white tracking-tight truncate">Security & Encryption Audit</h2>
|
||||
<p className="text-xs sm:text-sm text-slate-400 leading-normal sm:leading-relaxed">
|
||||
Monitor TLS versions distribution, cipher suites, active certificate properties, and device risk profiles.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Users, Plus } from "lucide-react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
role: string | null;
|
||||
onAddUserClick: (companyName: string) => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ role, onAddUserClick }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="max-w-md mx-auto py-12 px-6">
|
||||
<div className="relative overflow-hidden bg-white/[0.02] backdrop-blur-md border border-white/10 rounded-3xl p-8 text-center shadow-2xl shadow-black/50">
|
||||
{/* Ambient glow highlight */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative w-16 h-16 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-5 text-amber-400 shadow-lg shadow-amber-500/5">
|
||||
<Users className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<h3 className="relative text-white font-bold text-lg mb-1.5">No Company Accounts</h3>
|
||||
<p className="relative text-sm text-slate-400 mb-6 max-w-xs mx-auto">
|
||||
Create the first company tenant admin account to get started.
|
||||
</p>
|
||||
|
||||
{role === "SUPER_ADMIN" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAddUserClick("")}
|
||||
className="relative inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 text-white rounded-xl text-xs font-semibold shadow-md shadow-amber-600/10 hover:shadow-lg transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Company Admin
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,22 +3,26 @@
|
||||
|
||||
import { Column } from "@/components/ui/DataTable";
|
||||
import { type ManagedUser } from "@/lib/admin-api";
|
||||
import { Eye, Loader2 } from "lucide-react";
|
||||
import { Eye, Loader2, Lock, Unlock } from "lucide-react";
|
||||
|
||||
interface GetColumnsProps {
|
||||
role: string | null;
|
||||
currentUsername?: string | null;
|
||||
viewAsLoading?: string | null;
|
||||
unlockLoading?: string | null;
|
||||
onEditClick: (user: ManagedUser) => void;
|
||||
onViewAsClick?: (user: ManagedUser) => void;
|
||||
onUnlockClick?: (user: ManagedUser) => void;
|
||||
}
|
||||
|
||||
export function getColumns({
|
||||
role,
|
||||
currentUsername,
|
||||
viewAsLoading,
|
||||
unlockLoading,
|
||||
onEditClick,
|
||||
onViewAsClick,
|
||||
onUnlockClick,
|
||||
}: GetColumnsProps): Column<ManagedUser>[] {
|
||||
return [
|
||||
{
|
||||
@@ -28,7 +32,7 @@ export function getColumns({
|
||||
},
|
||||
{
|
||||
header: "Account Name",
|
||||
className: "w-[22%] text-center",
|
||||
className: "w-[20%] text-center",
|
||||
accessor: (row) => (
|
||||
<span className="text-sm font-medium text-slate-200">
|
||||
{row.account_name || row.username}
|
||||
@@ -37,7 +41,7 @@ export function getColumns({
|
||||
},
|
||||
{
|
||||
header: "Username",
|
||||
className: "w-[18%] text-center",
|
||||
className: "w-[16%] text-center",
|
||||
accessor: (row) => <code className="text-xs text-slate-400 font-mono">{row.username}</code>
|
||||
},
|
||||
{
|
||||
@@ -56,9 +60,28 @@ export function getColumns({
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
className: "w-[12%] text-center",
|
||||
accessor: (row) => {
|
||||
const isLocked = row.lockout_until && new Date(row.lockout_until) > new Date();
|
||||
if (isLocked) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/10 text-rose-400 border border-rose-500/30 uppercase tracking-wider">
|
||||
<Lock className="w-3 h-3" /> Locked
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 uppercase tracking-wider">
|
||||
Active
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
header: "Assigned Devices",
|
||||
className: "w-[15%] text-center",
|
||||
className: "w-[14%] text-center",
|
||||
accessor: (row) => {
|
||||
const uuids = row.agent_uuids || [];
|
||||
if (uuids.length === 0) return <span className="text-xs text-slate-500 italic">No device assigned</span>;
|
||||
@@ -75,7 +98,6 @@ export function getColumns({
|
||||
accessor: (row) => {
|
||||
const isSelf = currentUsername && row.username === currentUsername;
|
||||
const isCompanyAdmin = row.role === "COMPANY_ADMIN";
|
||||
// View-As tersedia untuk COMPANY_OPERATOR dan COMPANY_VIEWER (bukan diri sendiri, bukan admin)
|
||||
const canViewAs = onViewAsClick &&
|
||||
!isSelf &&
|
||||
!isCompanyAdmin &&
|
||||
@@ -107,20 +129,37 @@ export function getColumns({
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
className: "w-[12%] text-center",
|
||||
className: "w-[15%] text-center",
|
||||
accessor: (row) => {
|
||||
const isSelf = currentUsername && row.username === currentUsername;
|
||||
const isLocked = row.lockout_until && new Date(row.lockout_until) > new Date();
|
||||
const isDisabled = role === "EXECUTIVE" || isSelf;
|
||||
const isUnlocking = unlockLoading === row.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isSelf && onEditClick(row)}
|
||||
disabled={!!isDisabled}
|
||||
title={isSelf ? "Cannot edit your own account from here" : undefined}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed hover:underline font-medium cursor-pointer"
|
||||
>
|
||||
{isSelf ? "👤 (You)" : "⚙️ Edit User"}
|
||||
</button>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isLocked && onUnlockClick && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUnlockClick(row)}
|
||||
disabled={isUnlocking}
|
||||
title="Unlock account access"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[11px] font-semibold text-emerald-400 bg-emerald-400/10 border border-emerald-400/30 hover:bg-emerald-400/20 transition-all cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{isUnlocking ? <Loader2 className="w-3 h-3 animate-spin" /> : <Unlock className="w-3 h-3" />}
|
||||
<span>Unlock</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isSelf && onEditClick(row)}
|
||||
disabled={!!isDisabled}
|
||||
title={isSelf ? "Cannot edit your own account from here" : undefined}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed hover:underline font-medium cursor-pointer"
|
||||
>
|
||||
{isSelf ? "👤 (You)" : "⚙️ Edit"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Users, Plus } from "lucide-react";
|
||||
import { getAdminUsers, getViewAsHeaders, startViewAsUser, type ManagedUser } from "@/lib/admin-api";
|
||||
import { Loader2, Plus } from "lucide-react";
|
||||
import { getAdminUsers, getViewAsHeaders, startViewAsUser, unlockUser, type ManagedUser } from "@/lib/admin-api";
|
||||
|
||||
import { HelpTrigger } from "@/components/help/HelpTrigger";
|
||||
import ExternalAccountModal from "@/components/admin/ExternalAccountModal";
|
||||
import { getColumns } from "./columns";
|
||||
import CompanyCard, { CompanyGroup } from "./CompanyCard";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
|
||||
export default function UserAccountsPage() {
|
||||
const router = useRouter();
|
||||
@@ -23,10 +24,9 @@ export default function UserAccountsPage() {
|
||||
const [modalUser, setModalUser] = useState<ManagedUser | null>(null);
|
||||
const [targetCompany, setTargetCompany] = useState<string | null>(null);
|
||||
const [viewAsLoading, setViewAsLoading] = useState<string | null>(null);
|
||||
// Registry agents untuk lookup label agent (digunakan pada View-As banner)
|
||||
const [unlockLoading, setUnlockLoading] = useState<string | number | null>(null);
|
||||
const [agentRegistry, setAgentRegistry] = useState<Record<string, string>>({});
|
||||
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -55,7 +55,6 @@ export default function UserAccountsPage() {
|
||||
router.push("/");
|
||||
} else {
|
||||
loadData();
|
||||
// Juga load agent registry untuk label lookup (digunakan pada banner View-As)
|
||||
fetch("/api/dashboard/agents/list", { headers: getViewAsHeaders() })
|
||||
.then(r => r.json())
|
||||
.then(json => {
|
||||
@@ -78,11 +77,8 @@ export default function UserAccountsPage() {
|
||||
});
|
||||
}, [router]);
|
||||
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
// User Accounts = HANYA akun customer/client (COMPANY_* roles)
|
||||
// Role teknikal/operasional (SOC_ANALYST, EXECUTIVE, ENGINEER, dll) ada di halaman Agents
|
||||
const COMPANY_ROLES = ['COMPANY_ADMIN', 'COMPANY_OPERATOR', 'COMPANY_VIEWER'];
|
||||
const companyUsers = managedUsers.filter(u => COMPANY_ROLES.includes(u.role || '') && u.company_name);
|
||||
|
||||
@@ -114,21 +110,18 @@ export default function UserAccountsPage() {
|
||||
};
|
||||
|
||||
const handleViewAsUser = async (user: ManagedUser) => {
|
||||
// View-As berdasarkan agent_uuids pertama yang di-assign ke user target
|
||||
const agentUuids = user.agent_uuids || [];
|
||||
if (agentUuids.length === 0) {
|
||||
alert(`User "${user.account_name || user.username}" has no assigned agents. Cannot enter View-As mode.`);
|
||||
return;
|
||||
}
|
||||
const targetAgentUuid = agentUuids[0];
|
||||
// Gunakan label agent dari registry (bukan UUID) untuk banner yang informatif
|
||||
const agentLabel = agentRegistry[targetAgentUuid] || targetAgentUuid;
|
||||
const userName = user.account_name || user.username;
|
||||
const userRole = user.role;
|
||||
setViewAsLoading(user.username);
|
||||
try {
|
||||
// startViewAsUser menyimpan: agent_label (label agent), user_name, user_role, view_as_type='user'
|
||||
await startViewAsUser(targetAgentUuid, agentLabel, userName, userRole);
|
||||
await startViewAsUser(targetAgentUuid, agentLabel, userName, userRole, user.id);
|
||||
router.refresh();
|
||||
window.location.reload();
|
||||
} catch (err: any) {
|
||||
@@ -138,28 +131,55 @@ export default function UserAccountsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlockUser = async (user: ManagedUser) => {
|
||||
setUnlockLoading(user.id);
|
||||
try {
|
||||
const res = await unlockUser(user.id);
|
||||
if (res.ok) {
|
||||
await loadData();
|
||||
} else {
|
||||
alert('Failed to unlock user: ' + (res.error || 'Unknown error'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert('Failed to unlock user: ' + err.message);
|
||||
} finally {
|
||||
setUnlockLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = getColumns({
|
||||
role,
|
||||
currentUsername,
|
||||
viewAsLoading,
|
||||
unlockLoading: unlockLoading !== null ? String(unlockLoading) : null,
|
||||
onEditClick: handleEditClick,
|
||||
onViewAsClick: (role === "SUPER_ADMIN" || role === "COMPANY_ADMIN") ? handleViewAsUser : undefined,
|
||||
onUnlockClick: (role === "SUPER_ADMIN" || role === "COMPANY_ADMIN" || role === "TENANT_ADMIN") ? handleUnlockUser : undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-white flex items-center gap-3">
|
||||
User Accounts Directory
|
||||
{isLoading && <Loader2 className="w-5 h-5 animate-spin text-amber-500" />}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
<div className="space-y-6 pb-24 sm:pb-10 animate-fade-in">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>User Accounts Directory</span>
|
||||
{isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-amber-500 flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="user-accounts" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-1 leading-normal sm:leading-relaxed">
|
||||
Manage company tenant user access, roles, and device ownership delegation.
|
||||
</p>
|
||||
</div>
|
||||
<HelpTrigger pageId="user-accounts" />
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="user-accounts" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -175,32 +195,7 @@ export default function UserAccountsPage() {
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{companies.length === 0 ? (
|
||||
<div className="max-w-md mx-auto py-12 px-6">
|
||||
<div className="relative overflow-hidden bg-white/[0.02] backdrop-blur-md border border-white/10 rounded-3xl p-8 text-center shadow-2xl shadow-black/50">
|
||||
{/* Ambient glow highlight */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-48 h-48 bg-amber-500/5 rounded-full blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative w-16 h-16 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center mx-auto mb-5 text-amber-400 shadow-lg shadow-amber-500/5 animate-pulse-subtle">
|
||||
<Users className="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<h3 className="relative text-white font-bold text-lg mb-1.5">No Company Accounts</h3>
|
||||
<p className="relative text-sm text-slate-400 mb-6 max-w-xs mx-auto">
|
||||
Create the first company tenant admin account to get started.
|
||||
</p>
|
||||
|
||||
{role === "SUPER_ADMIN" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddUserClick("")}
|
||||
className="relative inline-flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-amber-600 to-amber-700 hover:from-amber-500 hover:to-amber-600 text-white rounded-xl text-xs font-semibold shadow-md shadow-amber-600/10 hover:shadow-lg transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Company Admin
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState role={role} onAddUserClick={handleAddUserClick} />
|
||||
) : (
|
||||
companies.map((company) => (
|
||||
<CompanyCard
|
||||
@@ -214,11 +209,11 @@ export default function UserAccountsPage() {
|
||||
)}
|
||||
|
||||
{role === "SUPER_ADMIN" && companies.length > 0 && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<div className="flex justify-end pt-2 pb-6 sm:pb-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddUserClick("")}
|
||||
className="flex items-center gap-2 px-5 py-2.5 text-xs font-semibold bg-amber-600 hover:bg-amber-700 text-white rounded-xl transition-all shadow-md shadow-amber-600/10 hover:shadow-lg cursor-pointer"
|
||||
className="w-full sm:w-auto flex items-center justify-center gap-2 px-5 py-3 text-xs font-semibold bg-amber-600 hover:bg-amber-700 text-white rounded-xl transition-all shadow-lg shadow-amber-600/20 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add New Company Admin
|
||||
@@ -228,7 +223,6 @@ export default function UserAccountsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Creation / Edit Modal */}
|
||||
<ExternalAccountModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
|
||||
@@ -26,7 +26,7 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
const route = routeArray.join('/');
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
|
||||
// Force ALL traffic to go through our own Centralized Proxy Server (Express Backend)
|
||||
const localBase = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:3001';
|
||||
const destinationUrl = `${localBase}/api/${route}${url.search}`;
|
||||
@@ -39,6 +39,11 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
} else {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const contentLength = req.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
// Forward cookie for authentication
|
||||
const cookie = req.headers.get('cookie');
|
||||
@@ -46,24 +51,23 @@ async function handleProxy(req: NextRequest, { params }: { params: Promise<{ rou
|
||||
headers.set('cookie', cookie);
|
||||
}
|
||||
|
||||
// Forward X-View-As-Agent header for view-as mode data filtering
|
||||
const viewAsHeader = req.headers.get('x-view-as-agent');
|
||||
if (viewAsHeader) {
|
||||
headers.set('x-view-as-agent', viewAsHeader);
|
||||
}
|
||||
// Forward all custom headers starting with x- (e.g. x-backone-site-uuid, x-view-as-agent)
|
||||
req.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase().startsWith('x-')) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const hasBody = !['GET', 'HEAD'].includes(req.method);
|
||||
let body = null;
|
||||
|
||||
if (hasBody) {
|
||||
body = await req.arrayBuffer();
|
||||
}
|
||||
const body = hasBody ? req.body : undefined;
|
||||
|
||||
const response = await fetch(destinationUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: body || undefined,
|
||||
body: body,
|
||||
cache: 'no-store', // Prevent Next.js from aggressively caching the backend response
|
||||
// @ts-ignore
|
||||
duplex: hasBody ? 'half' : undefined,
|
||||
});
|
||||
|
||||
const responseBuffer = await response.arrayBuffer();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3011";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
// Menerima JSON dari frontend (bukan multipart)
|
||||
const body = await req.json();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (token) {
|
||||
headers.set("Cookie", `token=${token}`);
|
||||
}
|
||||
req.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase().startsWith("x-")) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const destinationUrl = `${BACKEND_URL}/api/auth/admin/create-external-user`;
|
||||
|
||||
const response = await fetch(destinationUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const responseBuffer = await response.arrayBuffer();
|
||||
return new NextResponse(responseBuffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[Create External User Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Failed to connect to the server." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3011";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
// Baca body sebagai JSON (tidak ada multipart lagi)
|
||||
const body = await req.json();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (token) {
|
||||
headers.set("Cookie", `token=${token}`);
|
||||
}
|
||||
req.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase().startsWith("x-")) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const destinationUrl = `${BACKEND_URL}/api/auth/admin/update-agent-user`;
|
||||
|
||||
const response = await fetch(destinationUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const responseBuffer = await response.arrayBuffer();
|
||||
return new NextResponse(responseBuffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[Update Agent User Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Failed to connect to the server." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// src/app/api/auth/remove-profile-picture/route.ts
|
||||
// Next.js API Route — proxies remove-profile-picture ke backend Express.
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3001";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
const headers: HeadersInit = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Cookie"] = `token=${token}`;
|
||||
}
|
||||
|
||||
const backendRes = await fetch(
|
||||
`${BACKEND_URL}/api/auth/remove-profile-picture`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
const contentType = backendRes.headers.get("content-type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
const text = await backendRes.text();
|
||||
console.error("[Remove-Picture Proxy] Backend returned non-JSON:", text.slice(0, 200));
|
||||
return NextResponse.json(
|
||||
{ error: `Server error (HTTP ${backendRes.status}): Please try again.` },
|
||||
{ status: backendRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await backendRes.json();
|
||||
const response = NextResponse.json(data, { status: backendRes.status });
|
||||
|
||||
const setCookie = backendRes.headers.get("set-cookie");
|
||||
if (setCookie) {
|
||||
response.headers.set("set-cookie", setCookie);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
console.error("[Remove-Picture Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to connect to the server." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || "http://127.0.0.1:3011";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value;
|
||||
|
||||
// Menerima JSON (base64 image) dari frontend
|
||||
const body = await req.json();
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", "application/json");
|
||||
if (token) {
|
||||
headers.set("Cookie", `token=${token}`);
|
||||
}
|
||||
req.headers.forEach((value, name) => {
|
||||
if (name.toLowerCase().startsWith("x-")) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
const destinationUrl = `${BACKEND_URL}/api/auth/upload-profile-picture`;
|
||||
|
||||
const response = await fetch(destinationUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const responseBuffer = await response.arrayBuffer();
|
||||
return new NextResponse(responseBuffer, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"Content-Type": response.headers.get("content-type") || "application/json",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[Upload Profile Picture Proxy] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to connect to the server." },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
+15
-38
@@ -13,8 +13,18 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Auto-Hide Floating Help FAB when any Pop-Up Modal is active ── */
|
||||
body[style*="overflow: hidden"] .help-center-fab,
|
||||
body[style*="overflow:hidden"] .help-center-fab,
|
||||
body:has([role="dialog"]) .help-center-fab,
|
||||
body:has(.animate-modal-in) .help-center-fab {
|
||||
display: none !important;
|
||||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
color: var(--foreground);
|
||||
/* Optical sizing + smoothing */
|
||||
font-optical-sizing: auto;
|
||||
@@ -25,20 +35,15 @@ body {
|
||||
|
||||
/* 1-Second Dual-Layer Smooth Background Crossfade */
|
||||
|
||||
/* Dark Mode Background Layer (Active by default, fades out on .light) */
|
||||
body::before,
|
||||
body::after {
|
||||
/* Dark Mode Background Layer */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
transition: opacity 150ms ease-in-out;
|
||||
pointer-events: none;
|
||||
background-size: cover;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
body::before {
|
||||
background-color: #09090b;
|
||||
background-image:
|
||||
radial-gradient(circle at 0% 0%, rgba(30, 58, 138, 0.35) 0%, transparent 40%),
|
||||
@@ -47,34 +52,12 @@ body::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.light body::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Light Mode Background Layer (Hidden by default, fades in on .light) */
|
||||
body::after {
|
||||
background-color: #f1f5f9;
|
||||
background-image:
|
||||
radial-gradient(circle at 0% 0%, rgba(59, 130, 246, 0.1) 0px, transparent 40%),
|
||||
radial-gradient(circle at 100% 0%, rgba(239, 68, 68, 0.05) 0px, transparent 40%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.light body::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ambient Colors for Cards & Sidebars */
|
||||
:root {
|
||||
--ambient-card-bg: rgba(24, 24, 27, 0.7);
|
||||
--ambient-sidebar-bg: rgba(9, 9, 11, 0.95);
|
||||
}
|
||||
|
||||
.light {
|
||||
--ambient-card-bg: rgba(255, 255, 255, 0.9);
|
||||
--ambient-sidebar-bg: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
.bg-card {
|
||||
background-color: var(--ambient-card-bg) !important;
|
||||
background-image: none !important;
|
||||
@@ -100,7 +83,7 @@ canvas {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* View Transitions API (For perfect whole-screen crossfade including WebGL/Canvas) */
|
||||
/* View Transitions API */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 500ms;
|
||||
@@ -116,14 +99,8 @@ canvas {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--muted);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.light .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ── Modal Animations (0.5s) ── */
|
||||
|
||||
+25
-6
@@ -1,12 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import "./globals.css";
|
||||
|
||||
// Bypass google font remote fetch during offline compilation
|
||||
const inter = {
|
||||
variable: "font-sans",
|
||||
};
|
||||
// Inter font — local variable font file, matches demoplace.my.id
|
||||
// demoplace uses: --font-sans: var(--font-inter) which renders as Inter
|
||||
const inter = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "../../public/fonts/Inter-Variable.woff2",
|
||||
weight: "100 900",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
adjustFontFallback: false,
|
||||
});
|
||||
|
||||
const backoneFont = localFont({
|
||||
src: "../../public/fonts/BackOneLogo-Regular.ttf",
|
||||
@@ -21,6 +31,13 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -30,12 +47,14 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
className={`${inter.variable} ${backoneFont.variable} h-full antialiased dark`}
|
||||
style={{ colorScheme: "dark" }}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col" suppressHydrationWarning>
|
||||
<body className="min-h-full flex flex-col overflow-x-hidden bg-background text-foreground" suppressHydrationWarning>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
forcedTheme="dark"
|
||||
enableSystem={false}
|
||||
>
|
||||
{children}
|
||||
|
||||
+163
-115
@@ -1,115 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lock, User, Eye, EyeOff } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || "Login failed");
|
||||
}
|
||||
|
||||
window.location.href = "/";
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col justify-center items-center relative overflow-hidden">
|
||||
{/* Background Glows */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-red-600/20 blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-700/20 blur-[120px] pointer-events-none" />
|
||||
|
||||
<div className="z-10 w-full max-w-md p-8 bg-sidebar/80 backdrop-blur-2xl rounded-2xl border border-border/50 shadow-2xl relative">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<img src="/backone-logo.png" alt="BackOne" className="w-16 h-16 drop-shadow-[0_0_15px_rgba(220,38,38,0.5)] mb-4" />
|
||||
<h1 className="text-2xl text-center font-bold font-backone text-white tracking-normal leading-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||
BackOne Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 rounded-lg bg-red-500/10 border border-red-500/50 text-red-500 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Username</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-300">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-white transition-colors focus:outline-none"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 px-4 bg-gradient-to-r from-red-600 to-blue-700 hover:from-red-500 hover:to-blue-600 text-white font-medium rounded-lg shadow-[0_0_15px_rgba(220,38,38,0.3)] hover:shadow-[0_0_20px_rgba(220,38,38,0.5)] transition-all duration-300 flex items-center justify-center mt-6"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Lock, User, Eye, EyeOff, ShieldAlert, Timer } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [lockoutSeconds, setLockoutSeconds] = useState<number | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (lockoutSeconds === null || lockoutSeconds <= 0) return;
|
||||
const interval = setInterval(() => {
|
||||
setLockoutSeconds((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(interval);
|
||||
setError("");
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [lockoutSeconds]);
|
||||
|
||||
const formatSeconds = (sec: number) => {
|
||||
const mins = Math.floor(sec / 60);
|
||||
const secs = sec % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (lockoutSeconds !== null && lockoutSeconds > 0) return;
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
let data: any = {};
|
||||
const contentType = res.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
data = await res.json();
|
||||
} else {
|
||||
const text = await res.text();
|
||||
data = { error: text || `Server error (${res.status})` };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.lockout_seconds) {
|
||||
setLockoutSeconds(data.lockout_seconds);
|
||||
}
|
||||
throw new Error(data.error || data.message || "Login failed");
|
||||
}
|
||||
|
||||
// Selalu bersihkan state view-as lama saat login akun baru
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("backone_view_as");
|
||||
}
|
||||
|
||||
window.location.href = "/";
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isLockedOut = lockoutSeconds !== null && lockoutSeconds > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col justify-center items-center relative overflow-hidden">
|
||||
{/* Background Glows */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-red-600/20 blur-[120px] pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] rounded-full bg-blue-700/20 blur-[120px] pointer-events-none" />
|
||||
|
||||
<div className="z-10 w-full max-w-md p-8 bg-sidebar/80 backdrop-blur-2xl rounded-2xl border border-border/50 shadow-2xl relative">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<img src="/backone-logo.png" alt="BackOne" className="w-16 h-16 drop-shadow-[0_0_15px_rgba(220,38,38,0.5)] mb-4" />
|
||||
<h1 className="text-2xl text-center font-bold font-backone text-white tracking-normal leading-tight bg-clip-text text-transparent bg-gradient-to-r from-white to-white/70">
|
||||
BackOne Dashboard
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/40 text-red-400 text-xs sm:text-sm space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 font-medium leading-relaxed">{error}</div>
|
||||
</div>
|
||||
{isLockedOut && (
|
||||
<div className="flex items-center justify-center gap-2 pt-2 border-t border-red-500/20 font-mono font-bold text-red-300 text-base">
|
||||
<Timer className="w-4 h-4 animate-pulse" />
|
||||
<span>Unlock in {formatSeconds(lockoutSeconds)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
disabled={isLockedOut || loading}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all disabled:opacity-50"
|
||||
placeholder="Username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-muted-foreground" />
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
disabled={isLockedOut || loading}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-10 pr-10 py-2.5 bg-background border border-border rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all disabled:opacity-50"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLockedOut || loading}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-2.5 text-muted-foreground hover:text-white transition-colors focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || isLockedOut}
|
||||
className="w-full py-2.5 px-4 bg-gradient-to-r from-red-600 to-blue-700 hover:from-red-500 hover:to-blue-600 text-white font-medium rounded-lg shadow-[0_0_15px_rgba(220,38,38,0.3)] hover:shadow-[0_0_20px_rgba(220,38,38,0.5)] transition-all duration-300 flex items-center justify-center mt-6 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : isLockedOut ? (
|
||||
`Locked (${formatSeconds(lockoutSeconds)})`
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-122
@@ -1,34 +1,30 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Premium Dark Theme */
|
||||
--background: #09090b;
|
||||
/* Very dark zinc */
|
||||
--foreground: #fafafa;
|
||||
|
||||
--card: rgba(24, 24, 27, 0.7);
|
||||
/* Zinc 900 with transparency for glassmorphism */
|
||||
--card-foreground: #fafafa;
|
||||
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
--primary: #3b82f6;
|
||||
/* Blue 500 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(39, 39, 42, 0.8);
|
||||
/* Zinc 800 */
|
||||
--secondary-foreground: #fafafa;
|
||||
|
||||
--muted: rgba(39, 39, 42, 0.5);
|
||||
--muted-foreground: #a1a1aa;
|
||||
/* Zinc 400 */
|
||||
|
||||
--destructive: #ef4444;
|
||||
/* Red 500 */
|
||||
--destructive-foreground: #ffffff;
|
||||
|
||||
--sidebar: #09090b;
|
||||
|
||||
/* Overrides for hardcoded colors in Dark Mode (Normal) */
|
||||
/* Standardized Dark Theme Colors */
|
||||
--t-white: #ffffff;
|
||||
--t-black: #000000;
|
||||
--slate-900: #0f172a;
|
||||
@@ -42,118 +38,3 @@
|
||||
--slate-100: #f1f5f9;
|
||||
--slate-50: #f8fafc;
|
||||
}
|
||||
|
||||
.light {
|
||||
/* Clean Light Theme */
|
||||
--background: #f1f5f9;
|
||||
/* Slate 100 */
|
||||
--foreground: #0f172a;
|
||||
|
||||
--card: rgba(255, 255, 255, 0.9);
|
||||
--card-foreground: #0f172a;
|
||||
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
|
||||
--primary: #2563eb;
|
||||
/* Blue 600 */
|
||||
--primary-foreground: #ffffff;
|
||||
|
||||
--secondary: rgba(241, 245, 249, 0.8);
|
||||
/* Slate 100 */
|
||||
--secondary-foreground: #0f172a;
|
||||
|
||||
--muted: rgba(226, 232, 240, 0.5);
|
||||
/* Slate 200 */
|
||||
--muted-foreground: #64748b;
|
||||
/* Slate 500 */
|
||||
|
||||
--sidebar: #ffffff;
|
||||
|
||||
/* Overrides for hardcoded colors in Light Mode (Inverted) */
|
||||
--t-white: #0f172a;
|
||||
/* Make text-white become very dark slate */
|
||||
--t-black: #ffffff;
|
||||
--slate-900: #ffffff;
|
||||
--slate-800: #f8fafc;
|
||||
--slate-700: #f1f5f9;
|
||||
--slate-600: #e2e8f0;
|
||||
--slate-500: #cbd5e1;
|
||||
--slate-400: #64748b;
|
||||
/* Keep 400 slightly dark so it stays readable */
|
||||
--slate-300: #475569;
|
||||
--slate-200: #334155;
|
||||
--slate-100: #1e293b;
|
||||
--slate-50: #0f172a;
|
||||
|
||||
/* Enhance contrast for colored text and backgrounds in Light Mode */
|
||||
|
||||
/* RED */
|
||||
--color-red-300: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-400: #dc2626;
|
||||
/* red-600 */
|
||||
--color-red-500: #b91c1c;
|
||||
/* red-700 */
|
||||
--color-red-950: #ef4444;
|
||||
/* red-500 (so that 950/20 becomes a visible pink bg) */
|
||||
|
||||
/* ORANGE */
|
||||
--color-orange-300: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-400: #ea580c;
|
||||
/* orange-600 */
|
||||
--color-orange-500: #c2410c;
|
||||
/* orange-700 */
|
||||
--color-orange-950: #f97316;
|
||||
/* orange-500 */
|
||||
|
||||
/* YELLOW */
|
||||
--color-yellow-300: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-400: #ca8a04;
|
||||
/* yellow-600 */
|
||||
--color-yellow-500: #a16207;
|
||||
/* yellow-700 */
|
||||
--color-yellow-950: #eab308;
|
||||
/* yellow-500 */
|
||||
|
||||
/* GREEN */
|
||||
--color-green-300: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-400: #16a34a;
|
||||
/* green-600 */
|
||||
--color-green-500: #15803d;
|
||||
/* green-700 */
|
||||
--color-green-950: #22c55e;
|
||||
/* green-500 */
|
||||
|
||||
/* EMERALD */
|
||||
--color-emerald-300: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-400: #059669;
|
||||
/* emerald-600 */
|
||||
--color-emerald-500: #047857;
|
||||
/* emerald-700 */
|
||||
--color-emerald-950: #10b981;
|
||||
/* emerald-500 */
|
||||
|
||||
/* BLUE */
|
||||
--color-blue-300: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-400: #2563eb;
|
||||
/* blue-600 */
|
||||
--color-blue-500: #1d4ed8;
|
||||
/* blue-700 */
|
||||
--color-blue-950: #3b82f6;
|
||||
/* blue-500 */
|
||||
|
||||
/* PURPLE */
|
||||
--color-purple-300: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-400: #9333ea;
|
||||
/* purple-600 */
|
||||
--color-purple-500: #7e22ce;
|
||||
/* purple-700 */
|
||||
--color-purple-950: #a855f7;
|
||||
/* purple-500 */
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { RefObject } from "react";
|
||||
import { User, Key, Tag, Cpu, Upload } from "lucide-react";
|
||||
|
||||
interface AgentAccountFormFieldsProps {
|
||||
isEdit: boolean;
|
||||
username: string;
|
||||
setUsername: (val: string) => void;
|
||||
password: string;
|
||||
setPassword: (val: string) => void;
|
||||
accountName: string;
|
||||
setAccountName: (val: string) => void;
|
||||
agentUuid: string;
|
||||
previewPic: string | null;
|
||||
fileInputRef: RefObject<HTMLInputElement | null>;
|
||||
handlePicChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function AgentAccountFormFields({
|
||||
isEdit,
|
||||
username,
|
||||
setUsername,
|
||||
password,
|
||||
setPassword,
|
||||
accountName,
|
||||
setAccountName,
|
||||
agentUuid,
|
||||
previewPic,
|
||||
fileInputRef,
|
||||
handlePicChange,
|
||||
}: AgentAccountFormFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full border-2 border-dashed border-slate-600 flex items-center justify-center cursor-pointer hover:border-blue-500 transition-colors overflow-hidden bg-slate-800"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{previewPic ? (
|
||||
<img src={previewPic} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">Profile Picture</p>
|
||||
<p className="text-xs text-slate-500">Click to upload image</p>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePicChange} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Name (Label Agent)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={e => setAccountName(e.target.value)}
|
||||
placeholder="e.g., Gateway 007"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">This name will appear as a label in the Agents tab</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Cpu className="w-3.5 h-3.5 inline mr-1" />
|
||||
Agent UUID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentUuid}
|
||||
readOnly
|
||||
className="w-full px-3 py-2.5 bg-slate-800/50 border border-slate-700 rounded-lg text-slate-400 font-mono text-sm cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Login Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
placeholder="Username for login"
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
Password {isEdit && <span className="text-slate-500">(leave blank to keep unchanged)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "••••••••" : "New password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { X, User, Key, Tag, Cpu, Upload, Trash2, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { X, Trash2, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { createAdminAgentUser, updateAdminAgentUser, deleteAdminAgentUser } from "@/lib/admin-api";
|
||||
import { AgentAccountFormFields } from "./AgentAccountFormFields";
|
||||
|
||||
interface AgentAccountModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -11,7 +12,7 @@ interface AgentAccountModalProps {
|
||||
onSuccess: () => void;
|
||||
agentUuid: string;
|
||||
agentLabel: string;
|
||||
existingUser: ManagedUser | null; // null = create mode
|
||||
existingUser: ManagedUser | null;
|
||||
}
|
||||
|
||||
export default function AgentAccountModal({
|
||||
@@ -72,14 +73,31 @@ export default function AgentAccountModal({
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
const fd = new FormData();
|
||||
fd.append("user_id", String(existingUser!.id));
|
||||
if (username.trim()) fd.append("username", username.trim());
|
||||
if (password) fd.append("password", password);
|
||||
fd.append("account_name", accountName.trim());
|
||||
fd.append("agent_uuid", agentUuid);
|
||||
if (picFile) fd.append("profile_picture", picFile);
|
||||
await updateAdminAgentUser(fd);
|
||||
const payload: Record<string, any> = {
|
||||
user_id: String(existingUser!.id),
|
||||
account_name: accountName.trim(),
|
||||
agent_uuid: agentUuid,
|
||||
};
|
||||
if (username.trim()) payload.username = username.trim();
|
||||
if (password) payload.password = password;
|
||||
|
||||
await updateAdminAgentUser(payload);
|
||||
|
||||
if (picFile) {
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(picFile);
|
||||
});
|
||||
await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_picture_base64: base64, user_id: String(existingUser!.id) }),
|
||||
});
|
||||
}
|
||||
|
||||
setSuccess("Account successfully updated!");
|
||||
} else {
|
||||
await createAdminAgentUser({
|
||||
@@ -120,12 +138,9 @@ export default function AgentAccountModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div 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-blue-900/40 to-slate-900">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">
|
||||
@@ -139,88 +154,20 @@ export default function AgentAccountModal({
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{/* 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-blue-500 transition-colors overflow-hidden bg-slate-800"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{previewPic ? (
|
||||
<img src={previewPic} alt="Profile" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Upload className="w-6 h-6 text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">Profile Picture</p>
|
||||
<p className="text-xs text-slate-500">Click to upload image</p>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handlePicChange} />
|
||||
</div>
|
||||
<AgentAccountFormFields
|
||||
isEdit={isEdit}
|
||||
username={username}
|
||||
setUsername={setUsername}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
accountName={accountName}
|
||||
setAccountName={setAccountName}
|
||||
agentUuid={agentUuid}
|
||||
previewPic={previewPic}
|
||||
fileInputRef={fileInputRef}
|
||||
handlePicChange={handlePicChange}
|
||||
/>
|
||||
|
||||
{/* Account Name (Label) */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Name (Label Agent)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={e => setAccountName(e.target.value)}
|
||||
placeholder="e.g., Gateway 007"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">This name will appear as a label in the Agents tab</p>
|
||||
</div>
|
||||
|
||||
{/* Agent UUID (readonly info) */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Cpu className="w-3.5 h-3.5 inline mr-1" />
|
||||
Agent UUID
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentUuid}
|
||||
readOnly
|
||||
className="w-full px-3 py-2.5 bg-slate-800/50 border border-slate-700 rounded-lg text-slate-400 font-mono text-sm cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
Password {isEdit && <span className="text-slate-500">(leave blank to keep unchanged)</span>}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "••••••••" : "New password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
@@ -234,7 +181,6 @@ export default function AgentAccountModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
{isEdit && (
|
||||
<button
|
||||
@@ -244,14 +190,16 @@ export default function AgentAccountModal({
|
||||
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>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-lg border border-slate-600 text-slate-300 text-sm hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Cancel</button>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
@@ -265,5 +213,4 @@ export default function AgentAccountModal({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
interface AgentLocationFormFieldsProps {
|
||||
latitude: string;
|
||||
setLatitude: (val: string) => void;
|
||||
longitude: string;
|
||||
setLongitude: (val: string) => void;
|
||||
label: string;
|
||||
setLabel: (val: string) => void;
|
||||
}
|
||||
|
||||
export function AgentLocationFormFields({
|
||||
latitude,
|
||||
setLatitude,
|
||||
longitude,
|
||||
setLongitude,
|
||||
label,
|
||||
setLabel,
|
||||
}: AgentLocationFormFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Latitude</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={latitude}
|
||||
onChange={(e) => setLatitude(e.target.value)}
|
||||
placeholder="e.g. -6.2263304"
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Longitude</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={longitude}
|
||||
onChange={(e) => setLongitude(e.target.value)}
|
||||
placeholder="e.g. 106.4247322"
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Location Label (Optional)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Balaraja Office HQ"
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, MapPin, Loader2, CheckCircle2, AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { AgentLocationFormFields } from "./AgentLocationFormFields";
|
||||
|
||||
interface AgentLocationModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -135,7 +136,6 @@ export default function AgentLocationModal({
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -144,14 +144,12 @@ export default function AgentLocationModal({
|
||||
className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm"
|
||||
/>
|
||||
|
||||
{/* Modal Container */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-white/10 bg-slate-900/90 p-6 shadow-2xl backdrop-blur-xl"
|
||||
>
|
||||
{/* Close Button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-slate-400 hover:text-white transition-colors"
|
||||
@@ -159,7 +157,6 @@ export default function AgentLocationModal({
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<div className="rounded-lg bg-primary/25 p-2 text-primary">
|
||||
<MapPin className="h-5 w-5" />
|
||||
@@ -170,7 +167,6 @@ export default function AgentLocationModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error / Success Alerts */}
|
||||
{error && (
|
||||
<div className="mb-4 flex items-start gap-2.5 rounded-lg border border-red-500/10 bg-red-500/5 p-3 text-xs text-red-400">
|
||||
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||
@@ -184,50 +180,16 @@ export default function AgentLocationModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Latitude</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={latitude}
|
||||
onChange={(e) => setLatitude(e.target.value)}
|
||||
placeholder="e.g. -6.2263304"
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AgentLocationFormFields
|
||||
latitude={latitude}
|
||||
setLatitude={setLatitude}
|
||||
longitude={longitude}
|
||||
setLongitude={setLongitude}
|
||||
label={label}
|
||||
setLabel={setLabel}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Longitude</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={longitude}
|
||||
onChange={(e) => setLongitude(e.target.value)}
|
||||
placeholder="e.g. 106.4247322"
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-semibold text-slate-300">Location Label (Optional)</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Balaraja Office HQ"
|
||||
className="w-full rounded-xl border border-white/10 bg-slate-950/40 py-2.5 px-3.5 text-sm text-white placeholder-slate-500 outline-none transition-all focus:border-primary/50 focus:bg-slate-950/60 focus:ring-1 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex justify-between gap-3 pt-2">
|
||||
{currentLocation ? (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "@/components/ui/sharedPdfStyles";
|
||||
import { DeviceLabelItem } from "@/app/(dashboard)/device-labeling/columns";
|
||||
|
||||
interface DeviceLabelingPdfDocumentProps {
|
||||
devices: DeviceLabelItem[];
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
export default function DeviceLabelingPdfDocument({
|
||||
devices,
|
||||
reportDateStr,
|
||||
}: DeviceLabelingPdfDocumentProps) {
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>Device Asset Directory & Owner Management Report</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Device Asset Directory</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Section Title */}
|
||||
<Text style={sharedStyles.sectionTitle}>
|
||||
Registered MAC Address Directory ({devices.length} Assets)
|
||||
</Text>
|
||||
|
||||
{/* Table */}
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
<View style={{ width: "5%" }}><Text style={sharedStyles.tableHeaderCell}>#</Text></View>
|
||||
<View style={{ width: "22%" }}><Text style={sharedStyles.tableHeaderCell}>MAC Address</Text></View>
|
||||
<View style={{ width: "22%" }}><Text style={sharedStyles.tableHeaderCell}>Owner / Custom Label</Text></View>
|
||||
<View style={{ width: "21%" }}><Text style={sharedStyles.tableHeaderCell}>System Label</Text></View>
|
||||
<View style={{ width: "16%" }}><Text style={sharedStyles.tableHeaderCell}>Manufacturer</Text></View>
|
||||
<View style={{ width: "14%" }}><Text style={sharedStyles.tableHeaderCell}>Agent</Text></View>
|
||||
</View>
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No device assets recorded.</Text>
|
||||
</View>
|
||||
) : (
|
||||
devices.map((device, idx) => (
|
||||
<View key={device.mac_address || idx} style={sharedStyles.tableRow} wrap={false}>
|
||||
<View style={{ width: "5%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{idx + 1}</Text>
|
||||
</View>
|
||||
<View style={{ width: "22%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, fontFamily: "Helvetica-Bold" }}>
|
||||
{device.mac_address}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: "22%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, color: device.custom_label ? "#0284c7" : "#94a3b8" }}>
|
||||
{device.custom_label || "— Unassigned —"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: "21%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{device.default_label || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ width: "16%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{device.manufacturer || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ width: "14%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, color: "#7e22ce" }}>{device.agent_name || "—"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from "react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { Loader2, Monitor, Shield, Tag, Cpu, Clock, Network } from "lucide-react";
|
||||
import { Loader2, Monitor, Shield, Tag, Cpu, Clock, Network, ArrowDownToLine } from "lucide-react";
|
||||
|
||||
interface IpHistoryItem {
|
||||
ip_address: string;
|
||||
@@ -39,46 +39,57 @@ export default function DeviceMacDetailsModal({
|
||||
const [ips, setIps] = useState<IpHistoryItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const exportToPdf = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const { pdf } = await import("@react-pdf/renderer");
|
||||
const { default: DeviceMacPdfDocument } = await import("./DeviceMacPdfDocument");
|
||||
const reportDateStr = new Date().toLocaleString("id-ID", { timeZone: "Asia/Jakarta", day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit" }) + " WIB";
|
||||
const doc = <DeviceMacPdfDocument macAddress={macAddress} customLabel={customLabel} defaultLabel={defaultLabel} agentName={agentName} manufacturer={manufacturer} deviceType={deviceType} ips={ips} reportDateStr={reportDateStr} />;
|
||||
const blob = await pdf(doc).toBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `Device_MAC_Details_${macAddress.replace(/[:]/g, "")}.pdf`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Failed to export PDF:", err);
|
||||
alert("Failed to generate PDF report. Please try again.");
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !macAddress) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetch(`/api/dashboard/devices/mac-details?mac=${encodeURIComponent(macAddress)}`, {
|
||||
headers: getViewAsHeaders(),
|
||||
})
|
||||
fetch(`/api/dashboard/devices/mac-details?mac=${encodeURIComponent(macAddress)}`, { headers: getViewAsHeaders() })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.ok) {
|
||||
setIps(data.ips || []);
|
||||
} else {
|
||||
setError(data.error || "Failed to load device details.");
|
||||
}
|
||||
if (data.ok) setIps(data.ips || []);
|
||||
else setError(data.error || "Failed to load device details.");
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message || "An error occurred while loading details.");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
.catch((err) => setError(err.message || "An error occurred while loading details."))
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [isOpen, macAddress]);
|
||||
|
||||
const formatFriendlyDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "-";
|
||||
const DateCell = ({ dateStr }: { dateStr?: string }) => {
|
||||
if (!dateStr) return <span>-</span>;
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
const d = new Date(dateStr);
|
||||
const datePart = d.toLocaleDateString("id-ID", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
const timePart = d.toLocaleTimeString("id-ID", { hour: "2-digit", minute: "2-digit" }) + " WIB";
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center leading-tight py-0.5" title={`${datePart} ${timePart}`}>
|
||||
<span className="font-medium text-slate-300 text-[11px] whitespace-nowrap">{datePart}</span>
|
||||
<span className="text-[10px] text-slate-500 whitespace-nowrap">{timePart}</span>
|
||||
</div>
|
||||
);
|
||||
} catch { return <span>{dateStr}</span>; }
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -89,12 +100,12 @@ export default function DeviceMacDetailsModal({
|
||||
>
|
||||
<div className="space-y-6 max-h-[75vh] overflow-y-auto pr-1 custom-scrollbar">
|
||||
{/* Profile Card */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Tag className="w-3 h-3" /> MAC Address
|
||||
</span>
|
||||
<code className="text-xs text-white font-mono block">{macAddress}</code>
|
||||
<code className="text-xs text-white font-mono block truncate" title={macAddress}>{macAddress}</code>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
@@ -132,7 +143,7 @@ export default function DeviceMacDetailsModal({
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 flex items-center gap-1">
|
||||
<Shield className="w-3 h-3" /> Device Type
|
||||
</span>
|
||||
<span className="text-xs text-slate-200 block capitalize">{deviceType || "Unknown"}</span>
|
||||
<span className="text-xs text-slate-200 block capitalize truncate" title={deviceType}>{deviceType || "Unknown"}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white/[0.02] border border-white/5 rounded-xl space-y-1">
|
||||
@@ -165,47 +176,57 @@ export default function DeviceMacDetailsModal({
|
||||
No IP addresses recorded for this MAC address.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-white/5 rounded-xl overflow-hidden bg-white/[0.01]">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
<table className="w-full text-xs text-slate-300">
|
||||
<thead className="bg-white/[0.03] text-slate-400 uppercase text-[9px] tracking-wider sticky top-0 z-10 border-b border-white/5">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-center">IP Address</th>
|
||||
<th className="px-3 py-2 text-center">First Seen</th>
|
||||
<th className="px-3 py-2 text-center">Last Seen</th>
|
||||
<th className="px-3 py-2 text-center">Traffic (Down / Up)</th>
|
||||
<th className="px-3 py-2 text-center">Flows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{ips.map((ip) => (
|
||||
<tr key={ip.ip_address} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-3 py-2.5 font-mono text-center text-slate-200">
|
||||
<div className="border border-white/10 rounded-xl overflow-x-auto overflow-y-auto max-h-60 custom-scrollbar bg-[#090D16]">
|
||||
<table className="w-full text-xs text-slate-300 border-collapse table-fixed min-w-[520px]">
|
||||
<thead className="bg-[#0F172A] text-slate-300 uppercase text-[9px] tracking-wider border-b border-white/10 sticky top-0 z-10">
|
||||
<tr className="bg-[#0F172A]">
|
||||
<th className="px-2.5 py-2.5 text-center w-[25%]">IP Address</th>
|
||||
<th className="px-2 py-2.5 text-center w-[20%]">First Seen</th>
|
||||
<th className="px-2 py-2.5 text-center w-[20%]">Last Seen</th>
|
||||
<th className="px-2 py-2.5 text-center w-[23%]">Traffic (Down / Up)</th>
|
||||
<th className="px-2 py-2.5 text-center w-[12%]">Flows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{ips.map((ip) => (
|
||||
<tr key={ip.ip_address} className="hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-2.5 py-2.5 font-mono text-center text-slate-200">
|
||||
<div className="truncate w-full text-center font-semibold text-cyan-300 text-xs" title={ip.ip_address}>
|
||||
{ip.ip_address}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-slate-400">
|
||||
{formatFriendlyDate(ip.first_seen)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center text-slate-400 flex items-center justify-center gap-1">
|
||||
<Clock className="w-3 h-3 text-slate-500" />
|
||||
{formatFriendlyDate(ip.last_seen)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono">
|
||||
{fmtBytes(ip.download)} / {fmtBytes(ip.upload)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-center font-mono text-slate-400">
|
||||
{ip.flows}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-center text-slate-400">
|
||||
<DateCell dateStr={ip.first_seen} />
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-center text-slate-400">
|
||||
<DateCell dateStr={ip.last_seen} />
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-center font-mono text-[11px] whitespace-nowrap">
|
||||
<span className="text-cyan-400 font-semibold">{fmtBytes(ip.download)}</span>
|
||||
<span className="text-slate-600 mx-1">/</span>
|
||||
<span className="text-emerald-400 font-semibold">{fmtBytes(ip.upload)}</span>
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-center font-mono text-cyan-400 font-semibold text-xs">
|
||||
{ip.flows?.toLocaleString() ?? 0}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-white/5">
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isExporting || isLoading}
|
||||
onClick={exportToPdf}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-xs font-semibold transition-all disabled:opacity-50"
|
||||
>
|
||||
{isExporting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <ArrowDownToLine className="w-3.5 h-3.5" />}
|
||||
<span>{isExporting ? "Generating..." : "Export PDF"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
import { Document, Page, Text, View } from "@react-pdf/renderer";
|
||||
import { sharedStyles } from "@/components/ui/sharedPdfStyles";
|
||||
|
||||
interface IpHistoryItem {
|
||||
ip_address: string;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
download: number;
|
||||
upload: number;
|
||||
flows: number;
|
||||
}
|
||||
|
||||
interface DeviceMacPdfDocumentProps {
|
||||
macAddress: string;
|
||||
customLabel: string | null;
|
||||
defaultLabel: string;
|
||||
agentName: string;
|
||||
manufacturer: string;
|
||||
deviceType: string;
|
||||
ips: IpHistoryItem[];
|
||||
reportDateStr: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const k = 1000;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
function formatIndonesiaTime(dateStr?: string): string {
|
||||
if (!dateStr) return "—";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleString("id-ID", {
|
||||
timeZone: "Asia/Jakarta",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}) + " WIB";
|
||||
} catch {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
export default function DeviceMacPdfDocument({
|
||||
macAddress,
|
||||
customLabel,
|
||||
defaultLabel,
|
||||
agentName,
|
||||
manufacturer,
|
||||
deviceType,
|
||||
ips,
|
||||
reportDateStr,
|
||||
}: DeviceMacPdfDocumentProps) {
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={sharedStyles.page}>
|
||||
{/* Header */}
|
||||
<View style={sharedStyles.header}>
|
||||
<View>
|
||||
<Text style={sharedStyles.logoText}>Deep Package Inspection</Text>
|
||||
<Text style={sharedStyles.logoSubtext}>MAC Address Network Intelligence Report</Text>
|
||||
</View>
|
||||
<View style={sharedStyles.titleContainer}>
|
||||
<Text style={sharedStyles.title}>Device Network Details Report</Text>
|
||||
<Text style={sharedStyles.subtitle}>Generated at: {reportDateStr}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Profile Card */}
|
||||
<Text style={sharedStyles.sectionTitle}>Device Identification Profile</Text>
|
||||
<View style={sharedStyles.grid}>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "33%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>MAC Address</Text>
|
||||
<Text style={sharedStyles.gridValue}>{macAddress || "N/A"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "33%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Owner / Custom Label</Text>
|
||||
<Text style={{ ...sharedStyles.gridValue, color: customLabel ? "#0284c7" : "#64748b" }}>
|
||||
{customLabel || "— Unassigned —"}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "34%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>System Label</Text>
|
||||
<Text style={sharedStyles.gridValue}>{defaultLabel || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "33%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Manufacturer</Text>
|
||||
<Text style={sharedStyles.gridValue}>{manufacturer || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "33%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Device Type</Text>
|
||||
<Text style={sharedStyles.gridValue}>{deviceType || "Unknown"}</Text>
|
||||
</View>
|
||||
<View style={{ ...sharedStyles.gridCol, width: "34%" }}>
|
||||
<Text style={sharedStyles.gridLabel}>Network Agent</Text>
|
||||
<Text style={{ ...sharedStyles.gridValue, color: "#7e22ce" }}>{agentName || "N/A"}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Associated IPs Section */}
|
||||
<Text style={sharedStyles.sectionTitle}>
|
||||
Associated IP Address History ({ips.length} Records)
|
||||
</Text>
|
||||
|
||||
<View style={sharedStyles.table}>
|
||||
<View style={sharedStyles.tableHeader}>
|
||||
<View style={{ width: "25%" }}><Text style={sharedStyles.tableHeaderCell}>IP Address</Text></View>
|
||||
<View style={{ width: "22%" }}><Text style={sharedStyles.tableHeaderCell}>First Seen</Text></View>
|
||||
<View style={{ width: "22%" }}><Text style={sharedStyles.tableHeaderCell}>Last Seen</Text></View>
|
||||
<View style={{ width: "18%", textAlign: "right" }}><Text style={{ ...sharedStyles.tableHeaderCell, textAlign: "right" }}>Traffic (Down/Up)</Text></View>
|
||||
<View style={{ width: "13%", textAlign: "right" }}><Text style={{ ...sharedStyles.tableHeaderCell, textAlign: "right" }}>Flows</Text></View>
|
||||
</View>
|
||||
|
||||
{ips.length === 0 ? (
|
||||
<View style={sharedStyles.tableRow}>
|
||||
<Text style={sharedStyles.emptyText}>No IP history recorded for this MAC address.</Text>
|
||||
</View>
|
||||
) : (
|
||||
ips.map((ip, idx) => (
|
||||
<View key={ip.ip_address || idx} style={sharedStyles.tableRow} wrap={false}>
|
||||
<View style={{ width: "25%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, fontFamily: "Helvetica-Bold", color: "#0f172a" }}>
|
||||
{ip.ip_address}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: "22%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{formatIndonesiaTime(ip.first_seen)}</Text>
|
||||
</View>
|
||||
<View style={{ width: "22%" }}>
|
||||
<Text style={sharedStyles.tableCell}>{formatIndonesiaTime(ip.last_seen)}</Text>
|
||||
</View>
|
||||
<View style={{ width: "18%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: "right" }}>
|
||||
{formatBytes(ip.download)} / {formatBytes(ip.upload)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ width: "13%" }}>
|
||||
<Text style={{ ...sharedStyles.tableCell, textAlign: "right", color: "#0284c7" }}>
|
||||
{ip.flows?.toLocaleString() ?? 0}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { User, Key, Tag, Shield } from "lucide-react";
|
||||
import RoleSelect from "./RoleSelect";
|
||||
import AgentChecklist from "./AgentChecklist";
|
||||
import ProfilePictureInput from "./ProfilePictureInput";
|
||||
|
||||
interface ExternalAccountFormFieldsProps {
|
||||
isEdit: boolean;
|
||||
adminRole: string;
|
||||
modalContext: 'agents' | 'user-accounts';
|
||||
previewPic: string | null;
|
||||
handlePicChange: (file: File) => void;
|
||||
accountName: string;
|
||||
setAccountName: (val: string) => void;
|
||||
role: string;
|
||||
setRole: (val: string) => void;
|
||||
companyName: string;
|
||||
setCompanyName: (val: string) => void;
|
||||
availableAgents: string[];
|
||||
selectedAgents: string[];
|
||||
handleAgentCheck: (agentUuid: string, checked: boolean) => void;
|
||||
agentNamesMap: Record<string, string>;
|
||||
username: string;
|
||||
setUsername: (val: string) => void;
|
||||
password: string;
|
||||
setPassword: (val: string) => void;
|
||||
}
|
||||
|
||||
export function ExternalAccountFormFields({
|
||||
isEdit,
|
||||
adminRole,
|
||||
modalContext,
|
||||
previewPic,
|
||||
handlePicChange,
|
||||
accountName,
|
||||
setAccountName,
|
||||
role,
|
||||
setRole,
|
||||
companyName,
|
||||
setCompanyName,
|
||||
availableAgents,
|
||||
selectedAgents,
|
||||
handleAgentCheck,
|
||||
agentNamesMap,
|
||||
username,
|
||||
setUsername,
|
||||
password,
|
||||
setPassword,
|
||||
}: ExternalAccountFormFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<ProfilePictureInput previewPic={previewPic} onChange={handlePicChange} />
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={(e) => setAccountName(e.target.value)}
|
||||
placeholder="Full Name"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Shield className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Role
|
||||
</label>
|
||||
<RoleSelect adminRole={adminRole} value={role} onChange={setRole} modalContext={modalContext} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalContext === 'user-accounts' && adminRole === "SUPER_ADMIN" && (role === "COMPANY_ADMIN" || isEdit) && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
🏢 Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Nama Perusahaan (e.g. IFG)"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalContext === 'user-accounts' && (
|
||||
role === "COMPANY_ADMIN" ||
|
||||
role === "COMPANY_OPERATOR" ||
|
||||
role === "COMPANY_VIEWER"
|
||||
) && (
|
||||
<AgentChecklist
|
||||
availableAgents={availableAgents}
|
||||
selectedAgents={selectedAgents}
|
||||
onAgentCheck={handleAgentCheck}
|
||||
agentNamesMap={agentNamesMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Login Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username for login"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
{isEdit ? "Reset Password" : "Password"}{" "}
|
||||
{isEdit && (
|
||||
<span className="text-slate-500">(leave blank to keep unchanged)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "Type new password to override" : "Create password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, User, Key, Tag, Loader2, AlertCircle, CheckCircle2, Trash2, Shield } from "lucide-react";
|
||||
import { X, Loader2, AlertCircle, CheckCircle2, Trash2 } from "lucide-react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { useExternalAccountForm } from "./useExternalAccountForm";
|
||||
import RoleSelect from "./RoleSelect";
|
||||
import AgentChecklist from "./AgentChecklist";
|
||||
import ProfilePictureInput from "./ProfilePictureInput";
|
||||
import { ExternalAccountFormFields } from "./ExternalAccountFormFields";
|
||||
|
||||
interface ExternalAccountModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -24,8 +22,6 @@ export default function ExternalAccountModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
user,
|
||||
targetSiteUuid,
|
||||
targetCreatedBy,
|
||||
targetCompany,
|
||||
modalContext = 'user-accounts',
|
||||
}: ExternalAccountModalProps) {
|
||||
@@ -124,95 +120,27 @@ export default function ExternalAccountModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfilePictureInput previewPic={previewPic} onChange={handlePicChange} />
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Tag className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={accountName}
|
||||
onChange={(e) => setAccountName(e.target.value)}
|
||||
placeholder="Full Name"
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Shield className="w-3.5 h-3.5 inline mr-1" />
|
||||
Account Role
|
||||
</label>
|
||||
<RoleSelect adminRole={adminRole} value={role} onChange={setRole} modalContext={modalContext} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Company Name: hanya untuk User Accounts context */}
|
||||
{modalContext === 'user-accounts' && adminRole === "SUPER_ADMIN" && (role === "COMPANY_ADMIN" || isEdit) && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
🏢 Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Nama Perusahaan (e.g. IFG)"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Checklist: hanya untuk User Accounts context (COMPANY_*) */}
|
||||
{modalContext === 'user-accounts' && (
|
||||
role === "COMPANY_ADMIN" ||
|
||||
role === "COMPANY_OPERATOR" ||
|
||||
role === "COMPANY_VIEWER"
|
||||
) && (
|
||||
<AgentChecklist
|
||||
availableAgents={availableAgents}
|
||||
selectedAgents={selectedAgents}
|
||||
onAgentCheck={handleAgentCheck}
|
||||
agentNamesMap={agentNamesMap}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<User className="w-3.5 h-3.5 inline mr-1" />
|
||||
Login Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Username for login"
|
||||
required
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-400 mb-1.5">
|
||||
<Key className="w-3.5 h-3.5 inline mr-1" />
|
||||
{isEdit ? "Reset Password" : "Password"}{" "}
|
||||
{isEdit && (
|
||||
<span className="text-slate-500">(leave blank to keep unchanged)</span>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isEdit ? "Type new password to override" : "Create password"}
|
||||
required={!isEdit}
|
||||
className="w-full px-3 py-2.5 bg-slate-800 border border-slate-600 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-amber-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<ExternalAccountFormFields
|
||||
isEdit={isEdit}
|
||||
adminRole={adminRole}
|
||||
modalContext={modalContext}
|
||||
previewPic={previewPic}
|
||||
handlePicChange={handlePicChange}
|
||||
accountName={accountName}
|
||||
setAccountName={setAccountName}
|
||||
role={role}
|
||||
setRole={setRole}
|
||||
companyName={companyName}
|
||||
setCompanyName={setCompanyName}
|
||||
availableAgents={availableAgents}
|
||||
selectedAgents={selectedAgents}
|
||||
handleAgentCheck={handleAgentCheck}
|
||||
agentNamesMap={agentNamesMap}
|
||||
username={username}
|
||||
setUsername={setUsername}
|
||||
password={password}
|
||||
setPassword={setPassword}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-900/30 border border-red-800 text-red-300 text-sm">
|
||||
|
||||
@@ -54,5 +54,5 @@ export default function ViewAsAgentBanner() {
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ManagedUser } from "@/lib/admin-api";
|
||||
import { updateAdminAgentUser, deleteAdminAgentUser, createAdminExternalUser, getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { deleteAdminAgentUser, getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { submitExternalAccountForm } from "./useExternalAccountSubmit";
|
||||
|
||||
interface UseExternalAccountFormProps {
|
||||
isOpen: boolean;
|
||||
@@ -39,7 +40,6 @@ export function useExternalAccountForm({
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Get current admin user context & global agent registry
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
@@ -51,9 +51,7 @@ export function useExternalAccountForm({
|
||||
setAdminCompany(data.user.company_name || "");
|
||||
setAdminAgentUuids(data.user.agent_uuids || []);
|
||||
|
||||
// Default role berdasarkan konteks modal
|
||||
if (modalContext === 'agents') {
|
||||
// Agents: default ke TENANT_ADMIN (role teknikal)
|
||||
setRole("TENANT_ADMIN");
|
||||
} else if (data.user.role === "COMPANY_ADMIN") {
|
||||
setRole("COMPANY_OPERATOR");
|
||||
@@ -90,9 +88,8 @@ export function useExternalAccountForm({
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}, [isOpen]);
|
||||
}, [isOpen, modalContext]);
|
||||
|
||||
// Load existing user data when in edit mode
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
@@ -135,32 +132,24 @@ export function useExternalAccountForm({
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const isEdit = !!user;
|
||||
|
||||
setError("");
|
||||
setSuccess("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
if (username.trim()) fd.append("username", username.trim());
|
||||
if (password) fd.append("password", password);
|
||||
fd.append("account_name", accountName.trim());
|
||||
if (picFile) fd.append("profile_picture", picFile);
|
||||
|
||||
const finalCompany = adminRole === "COMPANY_ADMIN" ? adminCompany : companyName;
|
||||
if (finalCompany) fd.append("company_name", finalCompany);
|
||||
fd.append("agent_uuids", JSON.stringify(selectedAgents));
|
||||
|
||||
if (isEdit) {
|
||||
fd.append("user_id", String(user.id));
|
||||
await updateAdminAgentUser(fd);
|
||||
setSuccess(password ? "Password successfully reset & Account updated!" : "Account successfully updated!");
|
||||
} else {
|
||||
fd.append("role", role);
|
||||
await createAdminExternalUser(fd);
|
||||
setSuccess("External account successfully created!");
|
||||
}
|
||||
|
||||
const successMsg = await submitExternalAccountForm({
|
||||
user,
|
||||
username,
|
||||
password,
|
||||
accountName,
|
||||
role,
|
||||
selectedAgents,
|
||||
finalCompany,
|
||||
picFile,
|
||||
});
|
||||
|
||||
setSuccess(successMsg);
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
onClose();
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { updateAdminAgentUser, createAdminExternalUser } from "@/lib/admin-api";
|
||||
|
||||
export async function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitExternalAccountForm({
|
||||
user,
|
||||
username,
|
||||
password,
|
||||
accountName,
|
||||
role,
|
||||
selectedAgents,
|
||||
finalCompany,
|
||||
picFile,
|
||||
}: {
|
||||
user?: any;
|
||||
username: string;
|
||||
password?: string;
|
||||
accountName: string;
|
||||
role: string;
|
||||
selectedAgents: string[];
|
||||
finalCompany: string;
|
||||
picFile: File | null;
|
||||
}) {
|
||||
const isEdit = !!user;
|
||||
if (isEdit) {
|
||||
const payload: Record<string, any> = {
|
||||
user_id: String(user.id),
|
||||
account_name: accountName.trim(),
|
||||
agent_uuids: selectedAgents,
|
||||
};
|
||||
if (username.trim()) payload.username = username.trim();
|
||||
if (password) payload.password = password;
|
||||
if (finalCompany) payload.company_name = finalCompany;
|
||||
|
||||
await updateAdminAgentUser(payload);
|
||||
|
||||
if (picFile) {
|
||||
const base64 = await fileToBase64(picFile);
|
||||
await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_picture_base64: base64, user_id: String(user.id) }),
|
||||
});
|
||||
}
|
||||
|
||||
return password ? "Password successfully reset & Account updated!" : "Account successfully updated!";
|
||||
} else {
|
||||
const payload: Record<string, any> = {
|
||||
role,
|
||||
agent_uuids: selectedAgents,
|
||||
};
|
||||
if (username.trim()) payload.username = username.trim();
|
||||
if (password) payload.password = password;
|
||||
if (accountName.trim()) payload.account_name = accountName.trim();
|
||||
if (finalCompany) payload.company_name = finalCompany;
|
||||
|
||||
const createResult = await createAdminExternalUser(payload);
|
||||
|
||||
if (picFile && createResult.userId) {
|
||||
const base64 = await fileToBase64(picFile);
|
||||
await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_picture_base64: base64, user_id: createResult.userId }),
|
||||
});
|
||||
}
|
||||
|
||||
return "External account successfully created!";
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { MapPin, AlertTriangle, ChevronDown, ChevronUp, Plus, Info, Loader2 } from "lucide-react";
|
||||
import { MapPin, Info, Loader2 } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import {
|
||||
getFlowTooltipContent,
|
||||
getAgentTooltipContent,
|
||||
MAP_CSS_INJECT,
|
||||
type FlowDetail
|
||||
} from "./IndonesiaAgentMapHelpers";
|
||||
import { MAP_CSS_INJECT, type FlowDetail } from "./IndonesiaAgentMapHelpers";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
import { UnregisteredAgentsPanel } from "./UnregisteredAgentsPanel";
|
||||
import { bootMapInstance } from "./IndonesiaAgentMapBoot";
|
||||
|
||||
interface LocationData {
|
||||
agent_uuid: string;
|
||||
@@ -43,17 +40,15 @@ export function IndonesiaAgentMap({
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [loadingFlows, setLoadingFlows] = useState(true);
|
||||
const [flows, setFlows] = useState<FlowData[]>([]);
|
||||
const [mapError, setMapError] = useState(false);
|
||||
const [mapError] = useState(false);
|
||||
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const layerGroupRef = useRef<any>(null);
|
||||
const tileLayerRef = useRef<any>(null);
|
||||
const canvasRendererRef = useRef<any>(null);
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme !== "light";
|
||||
|
||||
// Agents that have no registered coordinate yet
|
||||
const unregisteredAgents = useMemo(() => {
|
||||
const registeredUuids = new Set(locations.map(l => l.agent_uuid));
|
||||
return agents.filter(a => {
|
||||
@@ -62,9 +57,6 @@ export function IndonesiaAgentMap({
|
||||
});
|
||||
}, [agents, locations]);
|
||||
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
|
||||
// Fetch Inter-Agent Flows
|
||||
useEffect(() => {
|
||||
setLoadingFlows(true);
|
||||
fetch("/api/dashboard/agent-flows", { headers: getViewAsHeaders() })
|
||||
@@ -78,7 +70,6 @@ export function IndonesiaAgentMap({
|
||||
.finally(() => setLoadingFlows(false));
|
||||
}, [locations]);
|
||||
|
||||
// Handle Map Resize & Alignment
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
@@ -104,250 +95,25 @@ export function IndonesiaAgentMap({
|
||||
};
|
||||
}, [agents, locations, flows]);
|
||||
|
||||
// Initialize and Update Map
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return;
|
||||
let mounted = true;
|
||||
let isMounted = true;
|
||||
|
||||
const buildMarkerHtml = (online: boolean): string => {
|
||||
return `
|
||||
<div class="custom-agent-marker ${online ? 'online' : 'offline'}">
|
||||
<div class="marker-beacon-ring"></div>
|
||||
<div class="marker-glass-disc">
|
||||
<div class="marker-core-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
bootMapInstance({
|
||||
mounted: () => isMounted,
|
||||
mapContainer: mapContainerRef.current!,
|
||||
agents,
|
||||
locations,
|
||||
flows,
|
||||
isDark,
|
||||
onEditLocation,
|
||||
mapInstanceRef,
|
||||
tileLayerRef,
|
||||
layerGroupRef,
|
||||
});
|
||||
|
||||
const boot = async () => {
|
||||
if (!mounted) return;
|
||||
const L = (await import("leaflet")).default;
|
||||
if (!mounted || !mapContainerRef.current) return;
|
||||
|
||||
// Inject CSS once
|
||||
if (!document.getElementById("lf-css-dpi")) {
|
||||
const lnk = document.createElement("link");
|
||||
lnk.id = "lf-css-dpi"; lnk.rel = "stylesheet";
|
||||
lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(lnk);
|
||||
}
|
||||
if (!document.getElementById("dpi-map-extra-css")) {
|
||||
const s = document.createElement("style");
|
||||
s.id = "dpi-map-extra-css";
|
||||
s.textContent = `
|
||||
@keyframes dpi-beacon { 0%,100%{opacity:.18;transform:scale(1);transform-origin:20px 20px;} 50%{opacity:.05;transform:scale(1.65);transform-origin:20px 20px;} }
|
||||
.dpi-beacon { animation: dpi-beacon 2.2s ease-in-out infinite; }
|
||||
@keyframes flowDash { to { stroke-dashoffset:-24; } }
|
||||
.animated-flow-line { animation: flowDash 1.2s linear infinite; }
|
||||
.agent-label-tooltip { background:transparent!important;border:none!important;box-shadow:none!important;padding:0!important; }
|
||||
.agent-label-tooltip::before { display:none!important; }
|
||||
.dpi-agent-label { display:inline-block;font-size:10px;font-weight:700;padding:3px 8px;border-radius:6px;border:1px solid;white-space:nowrap;letter-spacing:0.02em; }
|
||||
.leaflet-control-attribution { display:none!important; }
|
||||
.leaflet-custom-popup .leaflet-popup-content-wrapper { background:#0a1224!important; border:1px solid rgba(99,102,241,0.25)!important; box-shadow:0 12px 36px rgba(0,0,0,0.65)!important; padding:0!important; border-radius:12px!important; }
|
||||
.leaflet-custom-popup .leaflet-popup-content { margin:0!important;padding:0!important; }
|
||||
|
||||
/* Premium Map Marker Styles */
|
||||
.custom-agent-marker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.marker-beacon-ring {
|
||||
position: absolute;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.online .marker-beacon-ring {
|
||||
border: 2px solid #10b981;
|
||||
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
.offline .marker-beacon-ring {
|
||||
border: 2px solid #ef4444;
|
||||
animation: beacon-ping 2.5s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
|
||||
}
|
||||
@keyframes beacon-ping {
|
||||
0% { transform: scale(0.4); opacity: 0.85; }
|
||||
50% { opacity: 0.45; }
|
||||
100% { transform: scale(1.8); opacity: 0; }
|
||||
}
|
||||
.marker-glass-disc {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: rgba(10, 18, 36, 0.85);
|
||||
border: 2px solid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7), inset 0 1px 2px rgba(255, 255, 255, 0.15);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.online .marker-glass-disc {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.offline .marker-glass-disc {
|
||||
border-color: #ef4444;
|
||||
box-shadow: 0 0 10px rgba(239, 68, 68, 0.5), 0 4px 10px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.marker-core-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.online .marker-core-dot {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 6px #10b981;
|
||||
}
|
||||
.offline .marker-core-dot {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 6px #ef4444;
|
||||
}
|
||||
.custom-agent-marker:hover .marker-glass-disc {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
// Build agent map
|
||||
const agentMap: Record<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {};
|
||||
agents.forEach((agent) => {
|
||||
const uuid = agent.uuid || agent.serial || "";
|
||||
const loc = locations.find((l) => l.agent_uuid === uuid);
|
||||
if (loc) {
|
||||
agentMap[uuid] = {
|
||||
lat: loc.latitude,
|
||||
lng: loc.longitude,
|
||||
label: loc.label || "",
|
||||
agentLabel: agent.label || "",
|
||||
status: agent.activated ? "Online" : "Offline",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const activeCoords = Object.values(agentMap);
|
||||
const centerLat = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lat, 0) / activeCoords.length : -6.2088;
|
||||
const centerLng = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lng, 0) / activeCoords.length : 106.8456;
|
||||
|
||||
const tileUrl = isDark
|
||||
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
|
||||
|
||||
if (!mapInstanceRef.current) {
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
zoomAnimation: false, // prevents _leaflet_pos crash on React StrictMode remount
|
||||
fadeAnimation: false,
|
||||
markerZoomAnimation: false,
|
||||
}).setView([centerLat, centerLng], 10, { animate: false });
|
||||
|
||||
const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map);
|
||||
tileLayerRef.current = tiles;
|
||||
L.control.zoom({ position: "bottomright" }).addTo(map);
|
||||
mapInstanceRef.current = map;
|
||||
layerGroupRef.current = L.layerGroup().addTo(map);
|
||||
} else {
|
||||
const map = mapInstanceRef.current;
|
||||
if (tileLayerRef.current) tileLayerRef.current.setUrl(tileUrl);
|
||||
if (activeCoords.length > 0) map.panTo([centerLat, centerLng]);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
const group = layerGroupRef.current;
|
||||
group.clearLayers();
|
||||
const registeredUuids = new Set(locations.map(l => l.agent_uuid));
|
||||
|
||||
// ── A. FLOW LINES ────────────────────────────────────────────────────────
|
||||
flows.forEach((flow) => {
|
||||
const src = agentMap[flow.source];
|
||||
const dst = agentMap[flow.target];
|
||||
if (!src || !dst || !registeredUuids.has(flow.source) || !registeredUuids.has(flow.target)) return;
|
||||
const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.5), 8);
|
||||
const color = isDark ? "#38bdf8" : "#1d4ed8";
|
||||
|
||||
L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color: isDark ? "#0ea5e9" : "#3b82f6", weight: weight + 4, opacity: 0.12,
|
||||
}).addTo(group);
|
||||
|
||||
const baseLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color, weight, opacity: 0.5,
|
||||
}).addTo(group);
|
||||
|
||||
const animLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color, weight: weight * 0.55, opacity: 0.95, dashArray: "10 15",
|
||||
className: "animated-flow-line",
|
||||
}).addTo(group);
|
||||
|
||||
const tooltipHtml = getFlowTooltipContent(
|
||||
src.agentLabel, dst.agentLabel, flow.bytes, flow.flowsCount, flow.details || []
|
||||
);
|
||||
[baseLine, animLine].forEach(line => {
|
||||
line.bindTooltip(tooltipHtml, { sticky: true, className: "leaflet-custom-popup shadow-2xl" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── B. PREMIUM SVG MARKERS ───────────────────────────────────────────────
|
||||
Object.entries(agentMap).forEach(([uuid, info]) => {
|
||||
if (!mounted) return;
|
||||
const isOnline = info.status === "Online";
|
||||
const labelColor = isOnline ? "#34d399" : "#f87171";
|
||||
const borderCol = isOnline ? "#10b981" : "#ef4444";
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: buildMarkerHtml(isOnline),
|
||||
className: "",
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
tooltipAnchor: [0, -18],
|
||||
popupAnchor: [0, -18],
|
||||
});
|
||||
|
||||
const marker = L.marker([info.lat, info.lng], { icon });
|
||||
|
||||
const shortLabel = info.agentLabel.length > 24 ? info.agentLabel.slice(0, 22) + "…" : info.agentLabel;
|
||||
marker.bindTooltip(
|
||||
`<div class="dpi-agent-label" style="background:rgba(10,18,36,0.92);color:${labelColor};border-color:${borderCol};box-shadow:0 0 10px ${borderCol}44,0 2px 8px rgba(0,0,0,0.7);">` +
|
||||
`<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${borderCol};margin-right:5px;${isOnline?`box-shadow:0 0 6px ${borderCol}`:""}"></span>` +
|
||||
`${shortLabel}</div>`,
|
||||
{ permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false }
|
||||
);
|
||||
|
||||
const popupHtml = getAgentTooltipContent(
|
||||
uuid, info.agentLabel, info.status, isOnline, info.label, info.lat, info.lng
|
||||
);
|
||||
marker.bindPopup(popupHtml, { closeButton: false, className: "leaflet-custom-popup", offset: [0, -8] });
|
||||
marker.on("popupopen", () => {
|
||||
const btn = document.getElementById(`btn-edit-${uuid}`);
|
||||
if (btn) btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel));
|
||||
});
|
||||
|
||||
marker.addTo(group);
|
||||
});
|
||||
|
||||
// Fit bounds
|
||||
if (activeCoords.length > 1 && mounted) {
|
||||
const L2 = L as any;
|
||||
const bounds = L2.latLngBounds(activeCoords.map((c: any) => [c.lat, c.lng]));
|
||||
mapInstanceRef.current.fitBounds(bounds, { padding: [60, 60], maxZoom: 14, animate: false });
|
||||
}
|
||||
};
|
||||
|
||||
boot();
|
||||
return () => {
|
||||
mounted = false;
|
||||
// Properly destroy the Leaflet instance so React StrictMode double-invoke
|
||||
// and HMR remounts don't leave orphaned panes that trigger _leaflet_pos errors.
|
||||
isMounted = false;
|
||||
if (mapInstanceRef.current) {
|
||||
try { mapInstanceRef.current.remove(); } catch (_) {}
|
||||
mapInstanceRef.current = null;
|
||||
@@ -377,7 +143,6 @@ export function IndonesiaAgentMap({
|
||||
}`}>
|
||||
<style dangerouslySetInnerHTML={{ __html: MAP_CSS_INJECT }} />
|
||||
|
||||
{/* Header Info */}
|
||||
<div className="mb-3.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className={`h-4 w-4 ${isDark ? "text-primary" : "text-blue-600"}`} />
|
||||
@@ -401,7 +166,6 @@ export function IndonesiaAgentMap({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Map Element */}
|
||||
<div className={`relative overflow-hidden rounded-xl border ${
|
||||
isDark ? "border-white/5" : "border-slate-200"
|
||||
}`}>
|
||||
@@ -413,72 +177,12 @@ export function IndonesiaAgentMap({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agents Without Coordinates Panel */}
|
||||
{unregisteredAgents.length > 0 && (
|
||||
<div className={`mt-3 rounded-xl border transition-colors duration-200 ${
|
||||
isDark
|
||||
? "border-amber-500/20 bg-amber-500/5"
|
||||
: "border-blue-200 bg-blue-50"
|
||||
}`}>
|
||||
{/* Panel Header */}
|
||||
<button
|
||||
onClick={() => setPanelOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${isDark ? "text-amber-500" : "text-blue-500"}`} />
|
||||
<span className={`text-xs font-semibold ${
|
||||
isDark ? "text-amber-400" : "text-blue-700"
|
||||
}`}>
|
||||
{unregisteredAgents.length} Agent{unregisteredAgents.length > 1 ? "s" : ""} Without Coordinates{role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN' ? " — Click to Configure" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{panelOpen
|
||||
? <ChevronUp className={`h-3.5 w-3.5 ${isDark ? "text-amber-500" : "text-blue-400"}`} />
|
||||
: <ChevronDown className={`h-3.5 w-3.5 ${isDark ? "text-amber-500" : "text-blue-400"}`} />}
|
||||
</button>
|
||||
|
||||
{/* Agent Chips */}
|
||||
{panelOpen && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-3 pt-0.5">
|
||||
{unregisteredAgents.map(agent => {
|
||||
const uuid = agent.uuid || agent.serial || "";
|
||||
const isOnline = agent.activated;
|
||||
const isWritable = role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN';
|
||||
return (
|
||||
<button
|
||||
key={uuid}
|
||||
onClick={isWritable ? () => onEditLocation(uuid, agent.label || uuid) : undefined}
|
||||
disabled={!isWritable}
|
||||
className={`group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
isDark
|
||||
? isWritable
|
||||
? "border-white/10 bg-slate-900/60 text-slate-300 hover:border-primary/40 hover:bg-primary/10 hover:text-primary cursor-pointer"
|
||||
: "border-white/5 bg-slate-900/40 text-slate-500 cursor-default"
|
||||
: isWritable
|
||||
? "border-blue-200 bg-white text-slate-700 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700 cursor-pointer"
|
||||
: "border-slate-200 bg-slate-50 text-slate-400 cursor-default"
|
||||
}`}
|
||||
>
|
||||
<MapPin className={`h-3.5 w-3.5 shrink-0 ${
|
||||
isOnline ? "text-emerald-500 animate-pulse" : "text-red-400"
|
||||
}`} />
|
||||
<span className="max-w-[160px] truncate" title={agent.label}>{agent.label}</span>
|
||||
<span className={`font-mono text-[10px] truncate max-w-[80px] ${
|
||||
isDark ? "text-slate-500" : "text-slate-400"
|
||||
}`} title={uuid}>{uuid}</span>
|
||||
{isWritable && (
|
||||
<Plus className={`h-3 w-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity ${
|
||||
isDark ? "" : "text-blue-500"
|
||||
}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<UnregisteredAgentsPanel
|
||||
unregisteredAgents={unregisteredAgents}
|
||||
onEditLocation={onEditLocation}
|
||||
role={role}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
import { getFlowTooltipContent, getAgentTooltipContent, type FlowDetail } from "./IndonesiaAgentMapHelpers";
|
||||
|
||||
interface LocationData {
|
||||
agent_uuid: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface FlowData {
|
||||
source: string;
|
||||
target: string;
|
||||
bytes: number;
|
||||
flowsCount: number;
|
||||
details: FlowDetail[];
|
||||
}
|
||||
|
||||
export function buildMarkerHtml(online: boolean): string {
|
||||
return `
|
||||
<div class="custom-agent-marker ${online ? 'online' : 'offline'}">
|
||||
<div class="marker-beacon-ring"></div>
|
||||
<div class="marker-glass-disc">
|
||||
<div class="marker-core-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function bootMapInstance({
|
||||
mounted,
|
||||
mapContainer,
|
||||
agents,
|
||||
locations,
|
||||
flows,
|
||||
isDark,
|
||||
onEditLocation,
|
||||
mapInstanceRef,
|
||||
tileLayerRef,
|
||||
layerGroupRef,
|
||||
}: {
|
||||
mounted: () => boolean;
|
||||
mapContainer: HTMLDivElement;
|
||||
agents: Agent[];
|
||||
locations: LocationData[];
|
||||
flows: FlowData[];
|
||||
isDark: boolean;
|
||||
onEditLocation: (agentUuid: string, label: string) => void;
|
||||
mapInstanceRef: React.MutableRefObject<any>;
|
||||
tileLayerRef: React.MutableRefObject<any>;
|
||||
layerGroupRef: React.MutableRefObject<any>;
|
||||
}) {
|
||||
if (!mounted()) return;
|
||||
const L = (await import("leaflet")).default;
|
||||
if (!mounted() || !mapContainer) return;
|
||||
|
||||
if (!document.getElementById("lf-css-dpi")) {
|
||||
const lnk = document.createElement("link");
|
||||
lnk.id = "lf-css-dpi"; lnk.rel = "stylesheet";
|
||||
lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(lnk);
|
||||
}
|
||||
|
||||
const agentMap: Record<string, { lat: number; lng: number; label: string; agentLabel: string; status: string }> = {};
|
||||
agents.forEach((agent) => {
|
||||
const uuid = agent.uuid || agent.serial || "";
|
||||
const loc = locations.find((l) => l.agent_uuid === uuid);
|
||||
if (loc) {
|
||||
agentMap[uuid] = {
|
||||
lat: loc.latitude,
|
||||
lng: loc.longitude,
|
||||
label: loc.label || "",
|
||||
agentLabel: agent.label || "",
|
||||
status: agent.activated ? "Online" : "Offline",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const activeCoords = Object.values(agentMap);
|
||||
const centerLat = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lat, 0) / activeCoords.length : -6.2088;
|
||||
const centerLng = activeCoords.length > 0 ? activeCoords.reduce((s, v) => s + v.lng, 0) / activeCoords.length : 106.8456;
|
||||
|
||||
const tileUrl = isDark
|
||||
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
|
||||
|
||||
if (!mapInstanceRef.current) {
|
||||
const map = L.map(mapContainer, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
zoomAnimation: false,
|
||||
fadeAnimation: false,
|
||||
markerZoomAnimation: false,
|
||||
}).setView([centerLat, centerLng], 10, { animate: false });
|
||||
|
||||
const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map);
|
||||
tileLayerRef.current = tiles;
|
||||
L.control.zoom({ position: "bottomright" }).addTo(map);
|
||||
mapInstanceRef.current = map;
|
||||
layerGroupRef.current = L.layerGroup().addTo(map);
|
||||
} else {
|
||||
const map = mapInstanceRef.current;
|
||||
if (tileLayerRef.current) tileLayerRef.current.setUrl(tileUrl);
|
||||
if (activeCoords.length > 0) map.panTo([centerLat, centerLng]);
|
||||
}
|
||||
|
||||
if (!mounted()) return;
|
||||
const group = layerGroupRef.current;
|
||||
group.clearLayers();
|
||||
const registeredUuids = new Set(locations.map(l => l.agent_uuid));
|
||||
|
||||
flows.forEach((flow) => {
|
||||
const src = agentMap[flow.source];
|
||||
const dst = agentMap[flow.target];
|
||||
if (!src || !dst || !registeredUuids.has(flow.source) || !registeredUuids.has(flow.target)) return;
|
||||
const weight = Math.min(Math.max(flow.bytes / 1024 / 1024 / 80, 2.5), 8);
|
||||
const color = isDark ? "#38bdf8" : "#1d4ed8";
|
||||
|
||||
L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color: isDark ? "#0ea5e9" : "#3b82f6", weight: weight + 4, opacity: 0.12,
|
||||
}).addTo(group);
|
||||
|
||||
const baseLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color, weight, opacity: 0.5,
|
||||
}).addTo(group);
|
||||
|
||||
const animLine = L.polyline([[src.lat, src.lng], [dst.lat, dst.lng]], {
|
||||
color, weight: weight * 0.55, opacity: 0.95, dashArray: "10 15",
|
||||
className: "animated-flow-line",
|
||||
}).addTo(group);
|
||||
|
||||
const tooltipHtml = getFlowTooltipContent(
|
||||
src.agentLabel, dst.agentLabel, flow.bytes, flow.flowsCount, flow.details || []
|
||||
);
|
||||
[baseLine, animLine].forEach(line => {
|
||||
line.bindTooltip(tooltipHtml, { sticky: true, className: "leaflet-custom-popup shadow-2xl" });
|
||||
});
|
||||
});
|
||||
|
||||
Object.entries(agentMap).forEach(([uuid, info]) => {
|
||||
if (!mounted()) return;
|
||||
const isOnline = info.status === "Online";
|
||||
const labelColor = isOnline ? "#34d399" : "#f87171";
|
||||
const borderCol = isOnline ? "#10b981" : "#ef4444";
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: buildMarkerHtml(isOnline),
|
||||
className: "",
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
tooltipAnchor: [0, -18],
|
||||
popupAnchor: [0, -18],
|
||||
});
|
||||
|
||||
const marker = L.marker([info.lat, info.lng], { icon });
|
||||
|
||||
const shortLabel = info.agentLabel.length > 24 ? info.agentLabel.slice(0, 22) + "…" : info.agentLabel;
|
||||
marker.bindTooltip(
|
||||
`<div class="dpi-agent-label" style="background:rgba(10,18,36,0.92);color:${labelColor};border-color:${borderCol};box-shadow:0 0 10px ${borderCol}44,0 2px 8px rgba(0,0,0,0.7);">` +
|
||||
`<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${borderCol};margin-right:5px;${isOnline?`box-shadow:0 0 6px ${borderCol}`:""}"></span>` +
|
||||
`${shortLabel}</div>`,
|
||||
{ permanent: true, direction: "top", offset: [0, -10], className: "agent-label-tooltip", interactive: false }
|
||||
);
|
||||
|
||||
const popupHtml = getAgentTooltipContent(
|
||||
uuid, info.agentLabel, info.status, isOnline, info.label, info.lat, info.lng
|
||||
);
|
||||
marker.bindPopup(popupHtml, { closeButton: false, className: "leaflet-custom-popup", offset: [0, -8] });
|
||||
marker.on("popupopen", () => {
|
||||
const btn = document.getElementById(`btn-edit-${uuid}`);
|
||||
if (btn) btn.addEventListener("click", () => onEditLocation(uuid, info.agentLabel));
|
||||
});
|
||||
|
||||
marker.addTo(group);
|
||||
});
|
||||
|
||||
if (activeCoords.length > 1 && mounted()) {
|
||||
const L2 = L as any;
|
||||
const bounds = L2.latLngBounds(activeCoords.map((c: any) => [c.lat, c.lng]));
|
||||
mapInstanceRef.current.fitBounds(bounds, { padding: [60, 60], maxZoom: 14, animate: false });
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function KPICards({ summary }: KPICardsProps) {
|
||||
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">
|
||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<Link href="/flows" className="block h-full focus:outline-none focus:ring-2 focus:ring-primary/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-border/50 hover:bg-card/100 hover:border-blue-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '0ms' }}>
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
@@ -126,17 +126,17 @@ export function KPICards({ summary }: KPICardsProps) {
|
||||
</Link>
|
||||
|
||||
<Link href="/threats" className="block h-full focus:outline-none focus:ring-2 focus:ring-red-500/50 rounded-xl">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-red-50/90 dark:bg-card/80 backdrop-blur-xl border-red-200 dark:border-red-500/20 hover:bg-red-100 hover:dark:bg-card/100 hover:border-red-300 hover:dark:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className="absolute inset-0 bg-red-500/10 dark:bg-red-500/5 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 p-4 opacity-20 dark:opacity-10 group-hover:opacity-30 dark:group-hover:opacity-20 transition-opacity text-red-600 dark:text-red-500">
|
||||
<Card className="h-full flex flex-col justify-between group relative overflow-hidden bg-card/80 backdrop-blur-xl border-red-500/20 hover:bg-card/100 hover:border-red-500/50 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 cursor-pointer animate-slide-up" style={{ animationDelay: '200ms' }}>
|
||||
<div className="absolute inset-0 bg-red-500/5 pointer-events-none" />
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity text-red-500">
|
||||
<ShieldAlert className="h-16 w-16" />
|
||||
</div>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4 pb-2 relative z-10">
|
||||
<CardTitle className="text-sm font-semibold text-red-700 dark:text-red-400 group-hover:text-red-800 dark:group-hover:text-red-300 transition-colors">Threats</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-500 animate-pulse" />
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground group-hover:text-red-400 transition-colors">Threats</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-red-500 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 relative z-10">
|
||||
<div className="text-[25px] font-bold text-red-600 dark:text-red-500 tracking-tight whitespace-nowrap">
|
||||
<div className="text-[25px] font-bold text-red-500 tracking-tight whitespace-nowrap">
|
||||
{summary?.total_threats?.toLocaleString() || "0"}
|
||||
</div>
|
||||
<div className="text-xs text-transparent select-none mt-1.5 pointer-events-none"> </div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ResponsiveContainer, BarChart, Bar, CartesianGrid, XAxis, YAxis,
|
||||
Tooltip as RechartsTooltip, PieChart, Pie, Cell
|
||||
} from "recharts";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
import { fmtBytes, formatAppLabel } from "@/lib/utils";
|
||||
|
||||
interface TopWidgetsProps {
|
||||
topApps: {
|
||||
@@ -20,30 +20,44 @@ const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#3b82f6'
|
||||
|
||||
export function TopWidgets({ topApps, topProtocols }: TopWidgetsProps) {
|
||||
return (
|
||||
<div className="col-span-3 space-y-6">
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1">
|
||||
<div className="col-span-1 lg:col-span-3 space-y-6 w-full min-w-0">
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1 min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white">Top Apps (Download)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="w-full min-w-0">
|
||||
{(!topApps.data || topApps.data.length === 0) ? (
|
||||
<div className="h-[150px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No Data
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[150px] w-full">
|
||||
<div className="h-[150px] w-full min-w-0 relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topApps.data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis dataKey="app_label" stroke="#a1a1aa" fontSize={11} tickLine={false} axisLine={false} tickMargin={5} />
|
||||
<XAxis
|
||||
dataKey="app_label"
|
||||
stroke="#a1a1aa"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={5}
|
||||
tickFormatter={(val) => {
|
||||
const formatted = formatAppLabel(val);
|
||||
return formatted.length > 12 ? `${formatted.substring(0, 10)}...` : formatted;
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#a1a1aa"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => {
|
||||
const mb = value / 1048576;
|
||||
return `${mb.toFixed(0)}M`;
|
||||
if (value === 0) return '0';
|
||||
if (value >= 1073741824) return `${(value / 1073741824).toFixed(1)}GB`;
|
||||
if (value >= 1048576) return `${(value / 1048576).toFixed(1)}MB`;
|
||||
if (value >= 1024) return `${(value / 1024).toFixed(0)}KB`;
|
||||
return `${value}B`;
|
||||
}}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
@@ -66,52 +80,54 @@ export function TopWidgets({ topApps, topProtocols }: TopWidgetsProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1">
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 flex-1 min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-white">Top Protocols</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="w-full min-w-0">
|
||||
{(!topProtocols.data || topProtocols.data.length === 0) ? (
|
||||
<div className="h-[150px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No Data
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[150px] w-full flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={topProtocols.data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={70}
|
||||
paddingAngle={4}
|
||||
dataKey="download"
|
||||
nameKey="protocol_label"
|
||||
stroke="none"
|
||||
>
|
||||
{topProtocols.data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} className="hover:opacity-80 transition-opacity outline-none" />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(24, 24, 27, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: '#fafafa'
|
||||
}}
|
||||
itemStyle={{ color: '#fafafa', fontSize: '13px', fontWeight: 500 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center w-full min-w-0 relative gap-3 sm:gap-4">
|
||||
<div className="h-[150px] w-full sm:w-[50%] min-w-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={topProtocols.data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={45}
|
||||
outerRadius={70}
|
||||
paddingAngle={4}
|
||||
dataKey="download"
|
||||
nameKey="protocol_label"
|
||||
stroke="none"
|
||||
>
|
||||
{topProtocols.data.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} className="hover:opacity-80 transition-opacity outline-none" />
|
||||
))}
|
||||
</Pie>
|
||||
<RechartsTooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(24, 24, 27, 0.9)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '8px',
|
||||
color: '#fafafa'
|
||||
}}
|
||||
itemStyle={{ color: '#fafafa', fontSize: '13px', fontWeight: 500 }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{/* Custom Legend */}
|
||||
<div className="flex flex-col justify-center ml-2 space-y-3">
|
||||
<div className="flex flex-row sm:flex-col items-center sm:items-start justify-center flex-wrap gap-x-3.5 gap-y-1.5 ml-0 sm:ml-2">
|
||||
{topProtocols.data.slice(0, 5).map((entry, index) => (
|
||||
<div key={`${entry.protocol_label}-${index}`} className="flex items-center text-sm text-muted-foreground whitespace-nowrap group cursor-default">
|
||||
<div className="w-3.5 h-3.5 rounded-full mr-3 shrink-0 shadow-sm" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
|
||||
<span className="truncate max-w-[100px] group-hover:text-white transition-colors">{entry.protocol_label}</span>
|
||||
<div key={`${entry.protocol_label}-${index}`} className="flex items-center text-xs sm:text-sm text-muted-foreground whitespace-nowrap group cursor-default">
|
||||
<div className="w-2.5 h-2.5 sm:w-3.5 sm:h-3.5 rounded-full mr-1.5 sm:mr-3 shrink-0 shadow-sm" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
|
||||
<span className="truncate max-w-[80px] sm:max-w-[100px] group-hover:text-white transition-colors">{entry.protocol_label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { MapPin, AlertTriangle, ChevronDown, ChevronUp, Plus } from "lucide-react";
|
||||
import { Agent } from "@/lib/actions/agentsCore";
|
||||
|
||||
interface UnregisteredAgentsPanelProps {
|
||||
unregisteredAgents: Agent[];
|
||||
onEditLocation: (agentUuid: string, label: string) => void;
|
||||
role?: string | null;
|
||||
isDark: boolean;
|
||||
}
|
||||
|
||||
export function UnregisteredAgentsPanel({
|
||||
unregisteredAgents,
|
||||
onEditLocation,
|
||||
role,
|
||||
isDark,
|
||||
}: UnregisteredAgentsPanelProps) {
|
||||
const [panelOpen, setPanelOpen] = useState(true);
|
||||
|
||||
if (unregisteredAgents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`mt-3 rounded-xl border transition-colors duration-200 ${
|
||||
isDark
|
||||
? "border-amber-500/20 bg-amber-500/5"
|
||||
: "border-blue-200 bg-blue-50"
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => setPanelOpen(v => !v)}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${isDark ? "text-amber-500" : "text-blue-500"}`} />
|
||||
<span className={`text-xs font-semibold ${
|
||||
isDark ? "text-amber-400" : "text-blue-700"
|
||||
}`}>
|
||||
{unregisteredAgents.length} Agent{unregisteredAgents.length > 1 ? "s" : ""} Without Coordinates{role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN' ? " — Click to Configure" : ""}
|
||||
</span>
|
||||
</div>
|
||||
{panelOpen
|
||||
? <ChevronUp className={`h-3.5 w-3.5 ${isDark ? "text-amber-500" : "text-blue-400"}`} />
|
||||
: <ChevronDown className={`h-3.5 w-3.5 ${isDark ? "text-amber-500" : "text-blue-400"}`} />}
|
||||
</button>
|
||||
|
||||
{panelOpen && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-3 pt-0.5">
|
||||
{unregisteredAgents.map(agent => {
|
||||
const uuid = agent.uuid || agent.serial || "";
|
||||
const isOnline = agent.activated;
|
||||
const isWritable = role === 'SUPER_ADMIN' || role === 'TENANT_ADMIN';
|
||||
return (
|
||||
<button
|
||||
key={uuid}
|
||||
onClick={isWritable ? () => onEditLocation(uuid, agent.label || uuid) : undefined}
|
||||
disabled={!isWritable}
|
||||
className={`group flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs font-medium transition-all duration-150 ${
|
||||
isDark
|
||||
? isWritable
|
||||
? "border-white/10 bg-slate-900/60 text-slate-300 hover:border-primary/40 hover:bg-primary/10 hover:text-primary cursor-pointer"
|
||||
: "border-white/5 bg-slate-900/40 text-slate-500 cursor-default"
|
||||
: isWritable
|
||||
? "border-blue-200 bg-white text-slate-700 hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700 cursor-pointer"
|
||||
: "border-slate-200 bg-slate-50 text-slate-400 cursor-default"
|
||||
}`}
|
||||
>
|
||||
<MapPin className={`h-3.5 w-3.5 shrink-0 ${
|
||||
isOnline ? "text-emerald-500 animate-pulse" : "text-red-400"
|
||||
}`} />
|
||||
<span className="max-w-[160px] truncate" title={agent.label}>{agent.label}</span>
|
||||
<span className={`font-mono text-[10px] truncate max-w-[80px] ${
|
||||
isDark ? "text-slate-500" : "text-slate-400"
|
||||
}`} title={uuid}>{uuid}</span>
|
||||
{isWritable && (
|
||||
<Plus className={`h-3 w-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity ${
|
||||
isDark ? "" : "text-blue-500"
|
||||
}`} />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
"use client";
|
||||
|
||||
// src/components/dpi/MetadataDetailPanel.tsx
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Row-level detail modal for BackOne Metadata page.
|
||||
// Matches animation style of DeviceDetailModal and AgentDetailModal.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X, Loader2, Database, AlertCircle, Globe, Monitor, Cpu, Fingerprint, Hash, Terminal, Radio } from "lucide-react";
|
||||
import { useMetadataDetail, MetadataDetailType, MetadataDetailRow } from "@/lib/api-metadata-detail";
|
||||
import { useMetadataDetail, MetadataDetailType } from "@/lib/api-metadata-detail";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { fmtBytes, getKnownDomainService } from "@/lib/utils";
|
||||
import { COLS, getColGroup, TableHead, TableRow } from "./MetadataDetailTable";
|
||||
|
||||
// ─── Type config ───────────────────────────────────────────────────────────────
|
||||
interface TypeConfig {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -46,152 +40,6 @@ const COLOR_MAP: Record<string, { icon: string; ring: string; glow: string }> =
|
||||
teal: { icon: "text-teal-400", ring: "ring-teal-500/30", glow: "bg-teal-500" },
|
||||
};
|
||||
|
||||
// ─── Cell with overflow ellipsis + title tooltip ────────────────────────────────
|
||||
// ─── 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Column definitions with even percentage widths ────────────────────────────
|
||||
type ColDef = { header: string; width: string; align?: "center" };
|
||||
|
||||
const COLS: Record<string, ColDef[]> = {
|
||||
domain: [
|
||||
{ header: "Source IP", width: "w-[12.5%]" },
|
||||
{ header: "MAC Address", width: "w-[12.5%]" },
|
||||
{ header: "Device Name", width: "w-[12.5%]" },
|
||||
{ header: "Manufacturer", width: "w-[12.5%]" },
|
||||
{ header: "OS", width: "w-[12.5%]" },
|
||||
{ header: "Download", width: "w-[12.5%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[12.5%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[12.5%]", align: "center" },
|
||||
],
|
||||
device: [
|
||||
{ header: "IP Address", width: "w-[16.6%]" },
|
||||
{ header: "MAC Address", width: "w-[16.6%]" },
|
||||
{ header: "Device Name", width: "w-[16.6%]" },
|
||||
{ header: "Manufacturer", width: "w-[16.6%]" },
|
||||
{ header: "Download", width: "w-[16.6%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[16.6%]", align: "center" },
|
||||
],
|
||||
torrent: [
|
||||
{ header: "Agent", width: "w-[20%]" },
|
||||
{ header: "Torrent Label", width: "w-[20%]" },
|
||||
{ header: "Download", width: "w-[20%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[20%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[20%]", align: "center" },
|
||||
],
|
||||
ssh: [
|
||||
{ header: "Role", width: "w-[20%]", align: "center" },
|
||||
{ header: "Agent", width: "w-[20%]" },
|
||||
{ header: "Download", width: "w-[20%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[20%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[20%]", align: "center" },
|
||||
],
|
||||
default: [
|
||||
{ header: "Agent", width: "w-[25%]" },
|
||||
{ header: "Download", width: "w-[25%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[25%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[25%]", align: "center" },
|
||||
],
|
||||
};
|
||||
|
||||
function getColGroup(type: MetadataDetailType): string {
|
||||
if (["sni_hostname", "ssl_server_cn", "quic_hostname"].includes(type)) return "domain";
|
||||
if (["netbios_hostname", "os_label"].includes(type)) return "device";
|
||||
if (type === "bittorrent_hash") return "torrent";
|
||||
if (type === "ssh_version") return "ssh";
|
||||
return "default";
|
||||
}
|
||||
|
||||
// ─── Table Head ────────────────────────────────────────────────────────────────
|
||||
function TableHead({ cols }: { cols: ColDef[] }) {
|
||||
const base = "px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center";
|
||||
return (
|
||||
<tr>
|
||||
{cols.map((c) => (
|
||||
<th key={c.header} className={`${base} ${c.width}`}>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Table Row ─────────────────────────────────────────────────────────────────
|
||||
function TableRow({ row, group }: { row: MetadataDetailRow; group: string }) {
|
||||
const tr = "border-b border-white/[0.04] hover:bg-white/[0.04] transition-colors duration-150";
|
||||
const tc = "px-3 py-2.5 text-sm text-center align-middle";
|
||||
|
||||
if (group === "domain") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.src_ip || "—"} className="text-blue-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.mac_address || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.device_label || row.src_ip || "—"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={row.manufacturer || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.os_label || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "device") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.ip_address || "—"} className="text-blue-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.mac_address || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.device_label || "—"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={row.manufacturer || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "torrent") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.label || "Unknown"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "ssh") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}>
|
||||
<div className="flex justify-center">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
row.role === "Client" ? "bg-blue-500/20 text-blue-300" : "bg-purple-500/20 text-purple-300"
|
||||
}`}>{row.role || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Props ─────────────────────────────────────────────────────────────────────
|
||||
interface Props {
|
||||
type: MetadataDetailType | null;
|
||||
value: string | null;
|
||||
@@ -201,7 +49,6 @@ interface Props {
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// ─── Main Modal ────────────────────────────────────────────────────────────────
|
||||
export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props) {
|
||||
const isOpen = !!(type && value);
|
||||
const { data, isLoading, error } = useMetadataDetail(type, value, timeRange);
|
||||
@@ -258,18 +105,14 @@ export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props)
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 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"}`}
|
||||
className={`relative bg-card border border-border/50 rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 overflow-hidden font-sans ${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">
|
||||
@@ -297,7 +140,6 @@ export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Summary bar ── */}
|
||||
{!isLoading && data.length > 0 && (
|
||||
<div className="flex items-center gap-6 px-6 py-2.5 bg-white/[0.02] border-b border-white/[0.06] flex-shrink-0 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
@@ -315,7 +157,6 @@ export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 custom-scrollbar">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3">
|
||||
@@ -347,7 +188,6 @@ export function MetadataDetailPanel({ type, value, timeRange, onClose }: Props)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
{!isLoading && !error && data.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<Pagination
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { MetadataDetailRow, MetadataDetailType } from "@/lib/api-metadata-detail";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
export type ColDef = { header: string; width: string; align?: "center" };
|
||||
|
||||
export const COLS: Record<string, ColDef[]> = {
|
||||
domain: [
|
||||
{ header: "Source IP", width: "w-[12.5%]" },
|
||||
{ header: "MAC Address", width: "w-[12.5%]" },
|
||||
{ header: "Device Name", width: "w-[12.5%]" },
|
||||
{ header: "Manufacturer", width: "w-[12.5%]" },
|
||||
{ header: "OS", width: "w-[12.5%]" },
|
||||
{ header: "Download", width: "w-[12.5%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[12.5%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[12.5%]", align: "center" },
|
||||
],
|
||||
device: [
|
||||
{ header: "IP Address", width: "w-[16.6%]" },
|
||||
{ header: "MAC Address", width: "w-[16.6%]" },
|
||||
{ header: "Device Name", width: "w-[16.6%]" },
|
||||
{ header: "Manufacturer", width: "w-[16.6%]" },
|
||||
{ header: "Download", width: "w-[16.6%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[16.6%]", align: "center" },
|
||||
],
|
||||
torrent: [
|
||||
{ header: "Agent", width: "w-[20%]" },
|
||||
{ header: "Torrent Label", width: "w-[20%]" },
|
||||
{ header: "Download", width: "w-[20%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[20%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[20%]", align: "center" },
|
||||
],
|
||||
ssh: [
|
||||
{ header: "Role", width: "w-[20%]", align: "center" },
|
||||
{ header: "Agent", width: "w-[20%]" },
|
||||
{ header: "Download", width: "w-[20%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[20%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[20%]", align: "center" },
|
||||
],
|
||||
default: [
|
||||
{ header: "Agent", width: "w-[25%]" },
|
||||
{ header: "Download", width: "w-[25%]", align: "center" },
|
||||
{ header: "Upload", width: "w-[25%]", align: "center" },
|
||||
{ header: "Flows", width: "w-[25%]", align: "center" },
|
||||
],
|
||||
};
|
||||
|
||||
export function getColGroup(type: MetadataDetailType): string {
|
||||
if (["sni_hostname", "ssl_server_cn", "quic_hostname"].includes(type)) return "domain";
|
||||
if (["netbios_hostname", "os_label"].includes(type)) return "device";
|
||||
if (type === "bittorrent_hash") return "torrent";
|
||||
if (type === "ssh_version") return "ssh";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function Cell({ value, className = "", center = true }: { value: string | number; className?: string; center?: boolean }) {
|
||||
const str = String(value ?? "—");
|
||||
return (
|
||||
<div
|
||||
className={`truncate whitespace-nowrap overflow-hidden text-ellipsis ${center ? "text-center" : ""} ${className}`}
|
||||
title={str}
|
||||
>
|
||||
{str || "—"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableHead({ cols }: { cols: ColDef[] }) {
|
||||
const base = "px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-slate-400 border-b border-white/10 text-center";
|
||||
return (
|
||||
<tr>
|
||||
{cols.map((c) => (
|
||||
<th key={c.header} className={`${base} ${c.width}`}>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableRow({ row, group }: { row: MetadataDetailRow; group: string }) {
|
||||
const tr = "border-b border-white/[0.04] hover:bg-white/[0.04] transition-colors duration-150";
|
||||
const tc = "px-3 py-2.5 text-sm text-center align-middle";
|
||||
|
||||
if (group === "domain") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.src_ip || "—"} className="text-blue-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.mac_address || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.device_label || row.src_ip || "—"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={row.manufacturer || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.os_label || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "device") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.ip_address || "—"} className="text-blue-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={row.mac_address || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.device_label || "—"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={row.manufacturer || "—"} className="text-slate-400 text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "torrent") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={row.label || "Unknown"} className="text-slate-200" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
if (group === "ssh") return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}>
|
||||
<div className="flex justify-center">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
row.role === "Client" ? "bg-blue-500/20 text-blue-300" : "bg-purple-500/20 text-purple-300"
|
||||
}`}>{row.role || "—"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
return (
|
||||
<tr className={tr}>
|
||||
<td className={tc}><Cell value={row.agent_uuid || "—"} className="font-mono text-xs text-slate-400" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.download)} className="text-cyan-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={fmtBytes(row.upload)} className="text-emerald-400 font-mono text-xs" /></td>
|
||||
<td className={tc}><Cell value={(row.flows ?? 0).toLocaleString()} className="text-slate-300 font-mono text-xs" /></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -16,11 +16,12 @@ export function HelpTrigger({ pageId, className = "" }: HelpTriggerProps) {
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className={`group flex items-center gap-1.5 rounded-lg border border-white/8 bg-white/5 px-2.5 py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white hover:border-white/20 transition-all ${className}`}
|
||||
className={`group flex items-center gap-1.5 rounded-lg border border-white/8 bg-white/5 px-2 py-1 sm:px-2.5 sm:py-1.5 text-xs font-semibold text-slate-300 hover:bg-white/10 hover:text-white hover:border-white/20 transition-all ${className}`}
|
||||
title="Learn how to read data on this page"
|
||||
>
|
||||
<BookOpen className="h-3.5 w-3.5 text-blue-500 group-hover:text-blue-400 transition-colors" />
|
||||
<span>Learn This Page</span>
|
||||
<span className="sm:hidden">Learn</span>
|
||||
<span className="hidden sm:inline">Learn This Page</span>
|
||||
</button>
|
||||
|
||||
<ContextualHelpModal
|
||||
|
||||
@@ -34,9 +34,9 @@ export function AccountSettingsModal({
|
||||
title="Account Settings"
|
||||
className="sm:max-w-3xl sm:w-full h-[520px]"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-6 h-[430px]">
|
||||
<div className="flex flex-col md:flex-row gap-4 md:gap-6 h-[430px]">
|
||||
{/* Left Sidebar Layout (Fixed Width & Height) */}
|
||||
<div className="w-full md:w-[180px] flex flex-row md:flex-col gap-1 border-b md:border-b-0 md:border-r border-border/50 pb-4 md:pb-0 md:pr-4 shrink-0 overflow-x-auto md:overflow-x-visible custom-scrollbar h-full">
|
||||
<div className="w-full md:w-[180px] flex flex-row md:flex-col gap-1 border-b md:border-b-0 md:border-r border-border/50 pb-2.5 md:pb-0 md:pr-4 shrink-0 overflow-x-auto md:overflow-x-visible custom-scrollbar h-auto md:h-full">
|
||||
<button
|
||||
onClick={() => setActiveTab('account_name')}
|
||||
className={cn(
|
||||
@@ -101,7 +101,7 @@ export function AccountSettingsModal({
|
||||
</div>
|
||||
|
||||
{/* Right Content Pane (Fixed Height & Scrollable for strict Rule 9 size consistency) */}
|
||||
<div className="flex-1 min-w-0 h-full overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div className="flex-1 min-w-0 h-[320px] md:h-full overflow-y-auto pr-2 custom-scrollbar">
|
||||
{activeTab === 'account_name' && (
|
||||
<AccountNameTab user={user} setUser={setUser} onClose={onClose} />
|
||||
)}
|
||||
|
||||
@@ -2,173 +2,148 @@
|
||||
|
||||
import { ReactNode, useState, useEffect } from "react";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { getViewAsStatusSync, stopViewAs, getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { AlertTriangle, LogOut, Clock, ShieldAlert, Loader2 } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { getViewAsStatusSync, validateViewAsSession } from "@/lib/admin-api";
|
||||
import { HelpCenterFAB } from "@/components/ui/HelpCenterFAB";
|
||||
import { useInactivityTimeout } from "@/hooks/useInactivityTimeout";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getSiteBranding } from "@/lib/branding";
|
||||
import { MobileTopBar } from "./MobileTopBar";
|
||||
import { MobileBottomBar } from "./MobileBottomBar";
|
||||
import { ViewAsBanner } from "./ViewAsBanner";
|
||||
import { SessionTimeoutModal } from "./SessionTimeoutModal";
|
||||
|
||||
export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [viewAs, setViewAs] = useState<{ active: boolean; agent_label?: string; agent_uuid?: string }>({ active: false });
|
||||
const [viewAs, setViewAs] = useState<any>({ active: false });
|
||||
const [user, setUser] = useState<any>(null);
|
||||
|
||||
// Timeout dialog state
|
||||
const [showTimeoutWarning, setShowTimeoutWarning] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState(120);
|
||||
const [isRenewing, setIsRenewing] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setViewAs(getViewAsStatusSync());
|
||||
|
||||
// Fetch initial user session to get token exp time
|
||||
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.user) setUser(data.user);
|
||||
const branding = getSiteBranding();
|
||||
const getPageTitlePrefix = (path: string): string => {
|
||||
switch (path) {
|
||||
case "/": return "Overview Dashboard";
|
||||
case "/agents": return "Agents";
|
||||
case "/apps": return "Apps";
|
||||
case "/devices": return "Devices";
|
||||
case "/dns": return "DNS";
|
||||
case "/dpi-analytics": return "DPI MetaData";
|
||||
case "/events": return "Recent Events";
|
||||
case "/flows": return "Flows";
|
||||
case "/geography": return "Geo Traffic";
|
||||
case "/intelligence": return "Threat Intelligence";
|
||||
case "/lookup": return "App Lookup";
|
||||
case "/network-infrastructure": return "Network Topology";
|
||||
case "/network-intelligence": return "Traffic Categories";
|
||||
case "/threats": return "Detected Threats";
|
||||
case "/help": return "User Guide";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
const updateTitleAndFavicon = () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
const prefix = getPageTitlePrefix(pathname);
|
||||
document.title = prefix ? `${prefix} | ${branding.name} - Deep Package Inspection` : `${branding.name} - Deep Package Inspection`;
|
||||
const logoUrl = branding.logo;
|
||||
const links = document.querySelectorAll("link[rel*='icon']");
|
||||
if (links.length > 0) {
|
||||
links.forEach(l => (l as HTMLLinkElement).href = logoUrl);
|
||||
} else {
|
||||
const l = document.createElement('link');
|
||||
l.rel = 'icon';
|
||||
l.href = logoUrl;
|
||||
document.head.appendChild(l);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateTitleAndFavicon();
|
||||
const timer = setTimeout(updateTitleAndFavicon, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [pathname]);
|
||||
|
||||
const {
|
||||
showWarning: showTimeoutWarning,
|
||||
timeLeft,
|
||||
isRenewing,
|
||||
handleRenewSession,
|
||||
handleLogout
|
||||
} = useInactivityTimeout({
|
||||
timeoutSeconds: 60 * 60,
|
||||
warningSeconds: 2 * 60
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { credentials: 'include' })
|
||||
.then(res => {
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/login";
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.catch(console.error);
|
||||
.then(data => {
|
||||
if (data && data.user) {
|
||||
setUser(data.user);
|
||||
const originalRole = data.user._originalRole || data.user.role;
|
||||
setViewAs(validateViewAsSession(originalRole));
|
||||
} else {
|
||||
setViewAs(getViewAsStatusSync());
|
||||
}
|
||||
})
|
||||
.catch(() => setViewAs(getViewAsStatusSync()));
|
||||
}, []);
|
||||
|
||||
// Background timer to watch token expiration
|
||||
useEffect(() => {
|
||||
if (!user || !user.exp) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const remaining = user.exp - now;
|
||||
|
||||
if (remaining <= 120 && remaining > 0) {
|
||||
setTimeLeft(remaining);
|
||||
setShowTimeoutWarning(true);
|
||||
} else if (remaining <= 0) {
|
||||
// Session expired, force logout immediately
|
||||
clearInterval(timer);
|
||||
setShowTimeoutWarning(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('backone_view_as');
|
||||
}
|
||||
fetch('/api/auth/logout', { method: 'POST' })
|
||||
.then(() => {
|
||||
window.location.href = '/login';
|
||||
});
|
||||
} else {
|
||||
setShowTimeoutWarning(false);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [user]);
|
||||
|
||||
const handleRenewSession = async () => {
|
||||
setIsRenewing(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/renew', { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
if (json.ok && json.user) {
|
||||
setUser(json.user);
|
||||
setShowTimeoutWarning(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to renew session:", err);
|
||||
} finally {
|
||||
setIsRenewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('backone_view_as');
|
||||
}
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
window.location.href = '/login';
|
||||
} catch (err) {
|
||||
console.error("Failed to logout:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCountdown = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s.toString().padStart(2, '0')}s`;
|
||||
};
|
||||
|
||||
const handleExit = async () => {
|
||||
await stopViewAs();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-transparent transition-colors duration-500 relative">
|
||||
<Sidebar />
|
||||
|
||||
{/* Desktop Sidebar (hidden on mobile/tablet) */}
|
||||
<div className="hidden lg:flex h-full shrink-0">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Mobile / Tablet Drawer Sidebar (hidden on desktop) */}
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex lg:hidden transition-all duration-500 ease-in-out ${
|
||||
isMobileMenuOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 bg-slate-950/80 backdrop-blur-md"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
<div
|
||||
className={`relative z-10 flex w-72 max-w-[80vw] flex-col bg-card shadow-2xl h-full transform transition-transform duration-500 ease-in-out ${
|
||||
isMobileMenuOpen ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<Sidebar isMobile onClose={() => setIsMobileMenuOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden w-full min-w-0 relative transition-all duration-500 ease-in-out">
|
||||
{viewAs.active && (
|
||||
<div className="bg-gradient-to-r from-amber-600/90 via-amber-700/90 to-amber-900/90 border-b border-amber-500/30 px-6 py-2.5 flex items-center justify-between text-white shadow-lg backdrop-blur-sm z-30 animate-pulse-subtle">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-300 animate-bounce" />
|
||||
<span>
|
||||
You are currently viewing data as Network Agent: <strong className="text-amber-200">{viewAs.agent_label || "Unknown Agent"}</strong> <span className="text-xs text-white/70">({viewAs.agent_uuid})</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExit}
|
||||
className="flex items-center gap-1.5 px-3 py-1 bg-white/10 hover:bg-white/20 active:bg-white/30 border border-white/20 rounded-md text-xs font-semibold tracking-wide transition-all shadow-sm shrink-0"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
Exit View Mode
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-transparent p-4 md:p-6 transition-all duration-500 ease-in-out w-full relative">
|
||||
<div className="mx-auto w-full max-w-7xl space-y-6">
|
||||
<MobileTopBar onOpenMenu={() => setIsMobileMenuOpen(true)} />
|
||||
<ViewAsBanner viewAs={viewAs} />
|
||||
|
||||
<main suppressHydrationWarning className="flex-1 overflow-y-auto overflow-x-hidden bg-transparent p-2.5 sm:p-5 lg:p-8 pb-24 lg:pb-8 transition-all duration-500 ease-in-out w-full relative min-w-0">
|
||||
<div suppressHydrationWarning className="w-full min-w-0 space-y-4 sm:space-y-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Premium Session Timeout Warning Modal */}
|
||||
<Modal isOpen={showTimeoutWarning} onClose={() => {}} title="Session Security Alert">
|
||||
<div className="space-y-5 text-xs text-slate-300">
|
||||
<div className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
||||
<ShieldAlert className="w-5 h-5 text-amber-400 shrink-0" />
|
||||
<p className="font-medium text-slate-200">
|
||||
For your security, your active dashboard session will automatically expire due to inactivity.
|
||||
</p>
|
||||
</div>
|
||||
{/* Mobile Bottom Navigation Bar (< 1024px) */}
|
||||
<MobileBottomBar onOpenMenu={() => setIsMobileMenuOpen(true)} />
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-6 bg-slate-900/40 border border-white/5 rounded-2xl relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-blue-500/5 blur-3xl rounded-full" />
|
||||
<Clock className="w-8 h-8 text-blue-400 mb-2 relative animate-pulse" />
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest relative">Remaining Time</span>
|
||||
<span className="text-3xl font-black text-white font-mono tracking-tight mt-1 relative">
|
||||
{formatCountdown(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
<SessionTimeoutModal
|
||||
showWarning={showTimeoutWarning}
|
||||
timeLeft={timeLeft}
|
||||
isRenewing={isRenewing}
|
||||
onRenew={handleRenewSession}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="px-4 py-2 text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-xl transition-all"
|
||||
>
|
||||
Logout Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRenewSession}
|
||||
disabled={isRenewing}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/90 active:scale-[0.98] text-primary-foreground px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isRenewing ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Keep Me Logged In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Global Help Center Drawer */}
|
||||
<HelpCenterFAB />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import ViewAsAgentBanner from "@/components/admin/ViewAsAgentBanner";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
|
||||
|
||||
export function Header() {
|
||||
@@ -11,7 +10,7 @@ export function Header() {
|
||||
const [userRole, setUserRole] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||
fetch('/api/auth/me')
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
if (d.user?.role) setUserRole(d.user.role);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { LayoutDashboard, Laptop, Activity, ShieldAlert, Menu } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MobileBottomBarProps {
|
||||
onOpenMenu: () => void;
|
||||
}
|
||||
|
||||
export function MobileBottomBar({ onOpenMenu }: MobileBottomBarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const navItems = [
|
||||
{ name: "Overview", href: "/", icon: LayoutDashboard },
|
||||
{ name: "Devices", href: "/devices", icon: Laptop },
|
||||
{ name: "Flows", href: "/flows", icon: Activity },
|
||||
{ name: "Threats", href: "/threats", icon: ShieldAlert },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-40 lg:hidden bg-card/95 border-t border-border/80 backdrop-blur-2xl shadow-2xl px-1 py-1.5 flex items-center justify-between w-full">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-center justify-center py-1 px-1 rounded-xl transition-all duration-200 relative min-w-0 text-center",
|
||||
isActive
|
||||
? "text-primary font-bold"
|
||||
: "text-muted-foreground hover:text-foreground active:scale-95"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<span className="absolute top-0 w-6 h-0.5 bg-primary rounded-full shadow-[0_0_8px_rgba(59,130,246,0.8)]" />
|
||||
)}
|
||||
<Icon className={cn("w-4 h-4 sm:w-5 sm:h-5 mb-0.5 transition-transform duration-200 shrink-0", isActive && "scale-110 text-primary")} />
|
||||
<span className="text-[10px] sm:text-xs font-medium tracking-tight truncate w-full">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Menu Drawer Trigger */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMenu}
|
||||
className="flex-1 flex flex-col items-center justify-center py-1 px-1 rounded-xl min-w-0 text-center text-muted-foreground hover:text-foreground active:scale-95 transition-all duration-200"
|
||||
>
|
||||
<Menu className="w-4 h-4 sm:w-5 sm:h-5 mb-0.5 text-slate-300 shrink-0" />
|
||||
<span className="text-[10px] sm:text-xs font-medium tracking-tight text-slate-300 truncate w-full">Menu</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getSiteBranding } from "@/lib/branding";
|
||||
import SidebarNotifications from "./SidebarNotifications";
|
||||
|
||||
interface MobileTopBarProps {
|
||||
onOpenMenu: () => void;
|
||||
selectedSite?: string;
|
||||
}
|
||||
|
||||
export function MobileTopBar({ onOpenMenu, selectedSite }: MobileTopBarProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [site, setSite] = useState<string | undefined>(selectedSite);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (!selectedSite && typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem("backone_site_uuid");
|
||||
if (stored) setSite(stored);
|
||||
}
|
||||
}, [selectedSite]);
|
||||
|
||||
const branding = getSiteBranding(site);
|
||||
|
||||
const getPageTitle = (path: string): string => {
|
||||
switch (path) {
|
||||
case "/": return "Overview";
|
||||
case "/devices": return "Devices";
|
||||
case "/flows": return "Flows";
|
||||
case "/threats": return "Threats";
|
||||
case "/apps": return "Apps";
|
||||
case "/agents": return "Agents";
|
||||
case "/dns": return "DNS Traffic";
|
||||
case "/dpi-analytics": return "DPI Analytics";
|
||||
case "/events": return "Events";
|
||||
case "/geography": return "Geo Traffic";
|
||||
case "/intelligence": return "Intelligence";
|
||||
case "/lookup": return "App Catalog";
|
||||
case "/network-infrastructure": return "Topology";
|
||||
case "/network-intelligence": return "Categories";
|
||||
case "/user-accounts": return "User Accounts";
|
||||
case "/device-labeling": return "Labeling";
|
||||
default: return "Dashboard";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="lg:hidden flex items-center justify-between px-4 py-3 bg-card/90 border-b border-border/80 backdrop-blur-xl sticky top-0 z-30 w-full shrink-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex items-center gap-2 shrink-0" suppressHydrationWarning>
|
||||
<img
|
||||
src={mounted ? branding.logo : "/backone-logo.png"}
|
||||
alt={mounted ? branding.name : "Logo"}
|
||||
className="w-6 h-6 object-contain"
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-border/60 shrink-0" />
|
||||
<h1 className="font-bold text-sm text-white tracking-tight truncate">
|
||||
{getPageTitle(pathname)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center gap-1 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[10px] font-bold px-2 py-0.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="truncate max-w-[70px]">{mounted ? branding.name : "DPI"}</span>
|
||||
</div>
|
||||
<SidebarNotifications placement="topbar" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,13 +24,19 @@ export default function ProfilePictureTab({ user, setUser, onClose }: Props) {
|
||||
setErrorMsg("");
|
||||
setSuccessMsg("");
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('profile_picture', profilePicFile);
|
||||
|
||||
try {
|
||||
// Konversi file ke base64 data URL — menghindari masalah multipart/form-data
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||
reader.readAsDataURL(profilePicFile);
|
||||
});
|
||||
|
||||
const res = await fetch('/api/auth/upload-profile-picture', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profile_picture_base64: base64 }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { ShieldAlert, Clock, Loader2 } from "lucide-react";
|
||||
|
||||
interface SessionTimeoutModalProps {
|
||||
showWarning: boolean;
|
||||
timeLeft: number;
|
||||
isRenewing: boolean;
|
||||
onRenew: () => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export function SessionTimeoutModal({
|
||||
showWarning,
|
||||
timeLeft,
|
||||
isRenewing,
|
||||
onRenew,
|
||||
onLogout
|
||||
}: SessionTimeoutModalProps) {
|
||||
const formatCountdown = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s.toString().padStart(2, '0')}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={showWarning} onClose={() => {}} title="Session Security Alert">
|
||||
<div className="space-y-5 text-xs text-slate-300">
|
||||
<div className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl">
|
||||
<ShieldAlert className="w-5 h-5 text-amber-400 shrink-0" />
|
||||
<p className="font-medium text-slate-200">
|
||||
For your security, your active dashboard session will automatically expire due to inactivity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center p-6 bg-slate-900/40 border border-white/5 rounded-2xl relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-blue-500/5 blur-3xl rounded-full" />
|
||||
<Clock className="w-8 h-8 text-blue-400 mb-2 relative animate-pulse" />
|
||||
<span className="text-[10px] text-slate-500 uppercase tracking-widest relative">Remaining Time</span>
|
||||
<span className="text-3xl font-black text-white font-mono tracking-tight mt-1 relative">
|
||||
{formatCountdown(timeLeft)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
className="px-4 py-2 text-sm font-semibold text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-xl transition-all"
|
||||
>
|
||||
Logout Now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRenew}
|
||||
disabled={isRenewing}
|
||||
className="flex items-center gap-2 bg-primary hover:bg-primary/90 active:scale-[0.98] text-primary-foreground px-5 py-2.5 rounded-xl text-sm font-bold shadow-lg shadow-blue-500/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isRenewing ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
|
||||
Keep Me Logged In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +1,67 @@
|
||||
// src/components/layout/Sidebar.tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { X } from "lucide-react";
|
||||
import { AccountSettingsModal } from "./AccountSettingsModal";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { useTenantConfig } from "@/contexts/TenantConfigContext";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { NAVIGATION_GROUPS, TIME_OPTIONS } from "./SidebarData";
|
||||
import { NAVIGATION_GROUPS } from "./SidebarData";
|
||||
import SidebarProfile from "./SidebarProfile";
|
||||
import SidebarNotifications from "./SidebarNotifications";
|
||||
import { SidebarSiteSwitcher } from "./SidebarSiteSwitcher";
|
||||
import { SidebarTimeFilter } from "./SidebarTimeFilter";
|
||||
import SidebarSiteSelector from "./SidebarSiteSelector";
|
||||
import SidebarTimeSelector from "./SidebarTimeSelector";
|
||||
import { getSiteBranding } from "@/lib/branding";
|
||||
|
||||
export function Sidebar() {
|
||||
interface SidebarProps {
|
||||
isMobile?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ isMobile = false, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { timeRange, setTimeRange } = useTimeFilter();
|
||||
const { tenantConfig } = useTenantConfig();
|
||||
|
||||
const [user, setUser] = useState<{ username?: string; account_name?: string; profile_picture?: string; role?: string; agent_uuid?: string } | null>(null);
|
||||
const [user, setUser] = useState<{ username?: string; account_name?: string; profile_picture?: string; role?: string; agent_uuid?: string; agent_uuids?: string[]; company_name?: string; _isViewAsMode?: boolean } | null>(null);
|
||||
const [isAccountOpen, setIsAccountOpen] = useState(false);
|
||||
const [isTimeDropdownOpen, setIsTimeDropdownOpen] = useState(false);
|
||||
const [intervalText, setIntervalText] = useState<string>("");
|
||||
|
||||
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);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/login";
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.user) {
|
||||
if (data && data.user) {
|
||||
setUser(data.user);
|
||||
if (data.user.site_uuid && data.user.role !== 'SUPER_ADMIN') {
|
||||
const currentStored = localStorage.getItem('backone_site_uuid');
|
||||
if (currentStored !== data.user.site_uuid) {
|
||||
localStorage.setItem('backone_site_uuid', data.user.site_uuid);
|
||||
setSelectedSite(data.user.site_uuid);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Role-aware site UUID logic:
|
||||
// - Global roles (SUPER_ADMIN, EXECUTIVE, SOC_ANALYST, ENGINEER):
|
||||
// Prioritaskan pilihan yang TERSIMPAN di localStorage.
|
||||
// Hanya set dari DB jika belum ada pilihan tersimpan.
|
||||
// Ini agar site switcher tidak reset ke SIAB setiap page reload.
|
||||
// - Restricted roles (TENANT_ADMIN, AGENT_VIEWER, COMPANY_*):
|
||||
// Selalu pakai site_uuid dari DB (mereka tidak bisa ganti site).
|
||||
const globalRoles = ['SUPER_ADMIN', 'EXECUTIVE', 'SOC_ANALYST', 'ENGINEER'];
|
||||
const isGlobalRole = globalRoles.includes(data.user.role || '');
|
||||
const storedSiteUuid = localStorage.getItem('backone_site_uuid');
|
||||
|
||||
if (isGlobalRole) {
|
||||
// Pakai localStorage jika sudah ada, fallback ke DB, fallback ke SIAB default
|
||||
const siteToUse = storedSiteUuid || data.user.site_uuid || '6681452d_9cae_4ff4_8ae8_0d504774265e';
|
||||
localStorage.setItem('backone_site_uuid', siteToUse);
|
||||
setSelectedSite(siteToUse);
|
||||
} else if (data.user.site_uuid) {
|
||||
// Restricted roles: selalu pakai site_uuid dari DB (tidak bisa switch)
|
||||
localStorage.setItem('backone_site_uuid', data.user.site_uuid);
|
||||
setSelectedSite(data.user.site_uuid);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -70,76 +87,71 @@ export function Sidebar() {
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem('backone_site_uuid');
|
||||
if (stored) setSelectedSite(stored);
|
||||
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 branding = getSiteBranding(selectedSite);
|
||||
const isNexus = branding.name === "Nexus";
|
||||
|
||||
|
||||
|
||||
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)]">
|
||||
<div
|
||||
className="flex h-full max-h-screen flex-col border-r border-white/5 bg-black/20 backdrop-blur-3xl transition-all w-64 shrink-0 relative z-40 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.5)]"
|
||||
style={{ fontFamily: "'Times New Roman', Times, serif" }}
|
||||
>
|
||||
{/* Brand & Site Info */}
|
||||
<div className="flex flex-col p-2 shrink-0 gap-4 border-b border-white/5">
|
||||
<div className="flex flex-col items-center justify-center text-center w-full pt-4 pb-2 px-2 gap-2">
|
||||
<div className="flex flex-col p-2 shrink-0 gap-4 border-b border-white/5 relative">
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-3 right-3 p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-col items-center justify-center w-full pt-6 pb-3 px-3 text-center mx-auto" suppressHydrationWarning>
|
||||
<img
|
||||
src={tenantConfig?.brandLogo || "/backone-logo.png"}
|
||||
alt="Brand Logo"
|
||||
className="h-10 object-contain drop-shadow-[0_0_12px_rgba(255,0,0,0.2)] shrink-0"
|
||||
src={mounted ? branding.logo : "/backone-logo.png"}
|
||||
alt="Logo"
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"w-12 h-12 object-contain shrink-0 mb-3",
|
||||
(mounted && isNexus)
|
||||
? "drop-shadow-[0_0_12px_rgba(6,182,212,0.45)]"
|
||||
: "drop-shadow-[0_0_12px_rgba(168,85,247,0.45)]"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<span className="text-[9px] uppercase tracking-[0.1em] text-slate-400 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>
|
||||
<span className="text-[9.5px] uppercase tracking-[0.18em] text-slate-300 font-mono font-bold block">
|
||||
Deep Package Inspection
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 mt-2.5 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[10px] font-bold px-3 py-1 rounded-full w-fit">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
DPI ENGINE: ACTIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Site Switcher Widget */}
|
||||
<SidebarSiteSwitcher
|
||||
<SidebarSiteSelector
|
||||
user={user}
|
||||
selectedSite={selectedSite}
|
||||
isSiteDropdownOpen={isSiteDropdownOpen}
|
||||
setIsSiteDropdownOpen={setIsSiteDropdownOpen}
|
||||
siteDropdownRef={siteDropdownRef}
|
||||
handleSiteSelect={handleSiteSelect}
|
||||
getSiteLabel={getSiteLabel}
|
||||
/>
|
||||
|
||||
{/* Global Time Filter Widget */}
|
||||
<SidebarTimeFilter
|
||||
timeRange={timeRange}
|
||||
setTimeRange={setTimeRange}
|
||||
isTimeDropdownOpen={isTimeDropdownOpen}
|
||||
setIsTimeDropdownOpen={setIsTimeDropdownOpen}
|
||||
timeDropdownRef={timeDropdownRef}
|
||||
selectedTimeOption={selectedTimeOption}
|
||||
timeOptions={TIME_OPTIONS}
|
||||
/>
|
||||
<SidebarTimeSelector />
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
{/* Navigation (Flexible Scrollable Container for Rule 9) */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 custom-scrollbar h-full">
|
||||
{intervalText && (
|
||||
<div className="mb-4 rounded-lg bg-blue-500/5 border border-blue-500/10 p-2 text-center">
|
||||
@@ -152,7 +164,13 @@ export function Sidebar() {
|
||||
{NAVIGATION_GROUPS.map((group) => {
|
||||
const allowedItems = group.items.filter((item) => {
|
||||
if (item.href === "/agents") {
|
||||
return user?.role === "SUPER_ADMIN" || user?.role === "TENANT_ADMIN";
|
||||
return user?.role === "SUPER_ADMIN" || user?.role === "EXECUTIVE" || user?.role === "TENANT_ADMIN" || user?.role === "COMPANY_ADMIN" || user?.role === "COMPANY_OPERATOR";
|
||||
}
|
||||
if (item.href === "/device-labeling") {
|
||||
return user?.role === "SUPER_ADMIN" || user?.role === "EXECUTIVE" || user?.role === "TENANT_ADMIN" || user?.role === "COMPANY_ADMIN" || user?.role === "COMPANY_OPERATOR";
|
||||
}
|
||||
if (item.href === "/user-accounts") {
|
||||
return user?.role === "SUPER_ADMIN" || user?.role === "EXECUTIVE" || user?.role === "COMPANY_ADMIN";
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -171,8 +189,9 @@ export function Sidebar() {
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => onClose?.()}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-xl px-3 py-2 text-xs font-semibold tracking-wide transition-all duration-300",
|
||||
"group flex items-center gap-3 rounded-xl px-3 py-2 text-xs font-medium tracking-wide transition-all duration-300",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary border-l-2 border-primary font-bold shadow-md shadow-primary/5"
|
||||
: "text-slate-400 hover:bg-white/[0.02] hover:text-slate-200"
|
||||
@@ -198,7 +217,7 @@ export function Sidebar() {
|
||||
{/* Footer (Account & Notifications) */}
|
||||
<div className="mt-auto p-4 border-t border-white/5 bg-white/5 backdrop-blur-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<SidebarProfile user={user} onOpenSettings={() => setIsAccountOpen(true)} />
|
||||
<SidebarProfile user={user} onOpenSettings={() => { setIsAccountOpen(true); onClose?.(); }} />
|
||||
<SidebarNotifications />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import {
|
||||
LayoutDashboard, Server, Activity, Globe, Radio, Zap,
|
||||
ShieldAlert, Shield, Lock, AppWindow, Search, Router
|
||||
LayoutDashboard, Server, Activity, Globe, Radio,
|
||||
ShieldAlert, Shield, AppWindow, Search, Router,
|
||||
PieChart, Database, Network, Zap, Tag, Users
|
||||
} from "lucide-react";
|
||||
|
||||
export const NAVIGATION_GROUPS = [
|
||||
{
|
||||
title: "OVERVIEW",
|
||||
items: [
|
||||
{ name: "Dashboard", href: "/", icon: LayoutDashboard },
|
||||
{ name: "Overview Dashboard", href: "/", icon: LayoutDashboard },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "INFRASTRUCTURE",
|
||||
items: [
|
||||
{ name: "Topology", href: "/network-infrastructure", icon: Globe },
|
||||
{ name: "Network Topology", href: "/network-infrastructure", icon: Network },
|
||||
{ name: "Agents", href: "/agents", icon: Server },
|
||||
{ name: "Devices", href: "/devices", icon: Router },
|
||||
{ name: "Device Labeling", href: "/device-labeling", icon: Tag },
|
||||
{ name: "User Account", href: "/user-accounts", icon: Users },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -23,25 +26,19 @@ export const NAVIGATION_GROUPS = [
|
||||
items: [
|
||||
{ name: "Flows", href: "/flows", icon: Activity },
|
||||
{ name: "Apps", href: "/apps", icon: AppWindow },
|
||||
{ name: "Traffic Categories", href: "/network-intelligence", icon: Activity },
|
||||
{ name: "Traffic Categories", href: "/network-intelligence", icon: PieChart },
|
||||
{ name: "DNS", href: "/dns", icon: Radio },
|
||||
{ name: "Geo Traffic", href: "/geography", icon: Globe },
|
||||
{ name: "DPI MetaData", href: "/dpi-analytics", icon: Activity },
|
||||
{ name: "DPI MetaData", href: "/dpi-analytics", icon: Database },
|
||||
{ name: "App Lookup", href: "/lookup", icon: Search },
|
||||
]
|
||||
},
|
||||
{
|
||||
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: "Threat Intelligence", href: "/intelligence", icon: Shield },
|
||||
{ name: "Detected Threats", href: "/threats", icon: ShieldAlert },
|
||||
{ name: "Network Events", href: "/events", icon: Zap },
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -52,4 +49,5 @@ export const TIME_OPTIONS = [
|
||||
{ value: "1h", label: "Last 1 Hour" },
|
||||
{ value: "1d", label: "Last 24 Hours" },
|
||||
{ value: "7d", label: "Last 7 Days" },
|
||||
{ value: "30d", label: "Last 30 Days" },
|
||||
] as const;
|
||||
@@ -2,26 +2,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Bell, AlertTriangle, Info } from "lucide-react";
|
||||
import { Bell, AlertTriangle, Info, X } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useNotifications, AppNotification } from "./useNotifications";
|
||||
|
||||
export default function SidebarNotifications() {
|
||||
interface SidebarNotificationsProps {
|
||||
placement?: "sidebar" | "topbar";
|
||||
}
|
||||
|
||||
export default function SidebarNotifications({ placement = "sidebar" }: SidebarNotificationsProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const { notifications, unreadCount, readIds, markAllAsRead, markAsRead } = useNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleNotificationClick = (n: AppNotification) => {
|
||||
markAsRead(n.id);
|
||||
@@ -40,30 +58,83 @@ export default function SidebarNotifications() {
|
||||
return { bg: 'bg-blue-500/10 hover:bg-blue-500/15', border: 'border-blue-500/20', text: 'text-blue-400', icon: <Info className="w-4 h-4 text-blue-500 shrink-0" /> };
|
||||
};
|
||||
|
||||
const getPopoverPositionClass = (): string => {
|
||||
if (placement === "topbar") {
|
||||
return "fixed top-14 right-3 sm:right-6 w-[calc(100vw-24px)] max-w-sm z-[99999]";
|
||||
}
|
||||
return "fixed bottom-16 left-4 sm:left-72 w-[calc(100vw-32px)] sm:w-96 max-w-sm z-[99999]";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0" ref={dropdownRef}>
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative flex h-10 w-10 items-center justify-center rounded-xl text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
className={
|
||||
placement === "topbar"
|
||||
? "relative p-2 rounded-xl text-slate-300 hover:text-white hover:bg-secondary/80 transition-colors flex items-center justify-center"
|
||||
: "relative flex h-10 w-10 items-center justify-center rounded-xl text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<Bell className={placement === "topbar" ? "w-4 h-4" : "h-5 w-5"} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute right-2 top-2 flex h-3 w-3 items-center justify-center rounded-full bg-red-500 text-[8px] font-bold text-[#ffffff] shadow-lg ring-2 ring-transparent">
|
||||
<span
|
||||
className={
|
||||
placement === "topbar"
|
||||
? "absolute -right-0.5 -top-0.5 flex h-4 min-w-[16px] px-1 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white shadow-md ring-2 ring-card"
|
||||
: "absolute right-1.5 top-1.5 flex h-4 min-w-[16px] px-1 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white shadow-md ring-2 ring-card"
|
||||
}
|
||||
>
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-0 left-full ml-4 w-96 rounded-2xl border border-border bg-card/95 backdrop-blur-xl p-3 shadow-2xl z-50 animate-fade-in">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2 mb-2">
|
||||
<span className="text-sm font-semibold text-foreground">Notifikasi</span>
|
||||
{unreadCount > 0 && (
|
||||
<button onClick={markAllAsRead} className="text-xs text-primary hover:text-primary/80">Tandai dibaca</button>
|
||||
)}
|
||||
|
||||
{mounted && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`${getPopoverPositionClass()} rounded-2xl border border-border bg-card/95 backdrop-blur-xl p-3.5 shadow-2xl transition-all duration-500 ease-in-out transform ${
|
||||
placement === "topbar" ? "origin-top-right" : "origin-bottom-left"
|
||||
} ${
|
||||
isOpen
|
||||
? "opacity-100 scale-100 translate-y-0 pointer-events-auto"
|
||||
: "opacity-0 scale-95 -translate-y-2 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-2.5 mb-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bell className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm font-bold text-foreground">Notifications</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-red-500/20 border border-red-500/30 text-red-400 text-[10px] font-bold px-1.5 py-0.5 rounded-full">
|
||||
{unreadCount} new
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className="text-xs text-primary hover:text-primary/80 font-medium transition-colors"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-secondary/80 transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
|
||||
<div className="max-h-80 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">Tidak ada notifikasi</div>
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">No new notifications</div>
|
||||
) : (
|
||||
notifications.map((n) => {
|
||||
const isRead = readIds.includes(n.id);
|
||||
@@ -72,22 +143,24 @@ export default function SidebarNotifications() {
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => handleNotificationClick(n)}
|
||||
className={`flex gap-2 p-2 rounded-lg border transition-all cursor-pointer ${styles.bg} ${styles.border} ${!isRead ? 'ring-1 ring-primary/25 opacity-100' : 'opacity-60'}`}
|
||||
className={`flex gap-2.5 p-2.5 rounded-xl border transition-all cursor-pointer ${styles.bg} ${styles.border} ${!isRead ? 'ring-1 ring-primary/30 opacity-100' : 'opacity-60'}`}
|
||||
>
|
||||
{styles.icon}
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className={`text-xs font-semibold ${styles.text} line-clamp-1`}>{n.title}</span>
|
||||
<div className="flex-1 space-y-0.5 min-w-0">
|
||||
<div className="flex justify-between items-start gap-1">
|
||||
<span className={`text-xs font-bold ${styles.text} truncate`}>{n.title}</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground line-clamp-2">{n.message}</p>
|
||||
<p className="text-[11px] text-slate-300 line-clamp-2 leading-tight">{n.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { User as UserIcon, Sun, Moon, LogOut } from "lucide-react";
|
||||
import { User as UserIcon, LogOut, Eye } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import { SafeImage } from "../ui/SafeImage";
|
||||
import { getViewAsStatusSync } from "@/lib/admin-api";
|
||||
|
||||
const ROLE_DISPLAY_NAMES: Record<string, string> = {
|
||||
SUPER_ADMIN: 'Super Admin',
|
||||
EXECUTIVE: 'Executive',
|
||||
TENANT_ADMIN: 'Tenant Admin',
|
||||
SOC_ANALYST: 'SOC Analyst',
|
||||
ENGINEER: 'Engineer',
|
||||
AGENT_VIEWER: 'Agent Viewer',
|
||||
// Company-facing roles — client/customer accounts
|
||||
COMPANY_ADMIN: 'Company Admin',
|
||||
COMPANY_OPERATOR: 'Company Operator',
|
||||
COMPANY_VIEWER: 'Company Viewer',
|
||||
};
|
||||
|
||||
const getRoleDisplayName = (role?: string) => {
|
||||
@@ -27,9 +32,10 @@ interface ProfileProps {
|
||||
|
||||
export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
const [isAccountDropdownOpen, setIsAccountDropdownOpen] = useState(false);
|
||||
const [viewAsStatus, setViewAsStatus] = useState<any | null>(null);
|
||||
const accountDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -41,8 +47,34 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const roleLabel = getRoleDisplayName(user?.role);
|
||||
const initial = user?.account_name ? user.account_name.charAt(0) : (user?.username ? user.username.charAt(0) : (user?.role === 'SUPER_ADMIN' ? 'A' : 'V'));
|
||||
useEffect(() => {
|
||||
// Cek apakah sedang dalam mode View-As untuk menampilkan label yang benar
|
||||
const status = getViewAsStatusSync();
|
||||
setViewAsStatus(status);
|
||||
}, []);
|
||||
|
||||
// Jika dalam View-As mode, gunakan label dari view-as (bukan role dari server yang jadi AGENT_VIEWER)
|
||||
const isViewAsMode = viewAsStatus?.active;
|
||||
|
||||
// Bangun label yang ditampilkan di sidebar bawah profil
|
||||
const roleLabel = (() => {
|
||||
if (!isViewAsMode) return getRoleDisplayName(user?.role);
|
||||
|
||||
const viewAsType = viewAsStatus?.view_as_type;
|
||||
if (viewAsType === 'user') {
|
||||
const userName = viewAsStatus?.user_name || 'Unknown';
|
||||
const userRole = viewAsStatus?.user_role || '';
|
||||
const roleShort = ROLE_DISPLAY_NAMES[userRole] || userRole.replace(/_/g, ' ');
|
||||
return `Viewing as: ${userName} (${roleShort})`;
|
||||
}
|
||||
// Direct agent view-as: tampilkan label agent
|
||||
const agentLabel = viewAsStatus?.agent_label || viewAsStatus?.agent_uuid || 'Unknown';
|
||||
return `Viewing Agent: ${agentLabel}`;
|
||||
})();
|
||||
|
||||
const initial = user?.account_name
|
||||
? user.account_name.charAt(0)
|
||||
: (user?.username ? user.username.charAt(0) : 'U');
|
||||
|
||||
return (
|
||||
<div className="relative flex-1 min-w-0" ref={accountDropdownRef}>
|
||||
@@ -60,13 +92,18 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start overflow-hidden text-left min-w-0 flex-1">
|
||||
<span className="text-sm font-semibold text-foreground truncate w-full">{user?.account_name || user?.username || 'Loading...'}</span>
|
||||
<span className="text-[10px] text-primary truncate w-full">{roleLabel}</span>
|
||||
<span className="text-sm font-semibold text-foreground truncate w-full">
|
||||
{user?.account_name || user?.username || 'Loading...'}
|
||||
</span>
|
||||
<span className={`text-[10px] truncate w-full ${isViewAsMode ? 'text-amber-400' : 'text-primary'}`}>
|
||||
{isViewAsMode && <Eye className="inline w-2.5 h-2.5 mr-0.5" />}
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isAccountDropdownOpen && (
|
||||
<div className="absolute bottom-0 left-full ml-4 w-56 rounded-xl border border-border bg-card/95 backdrop-blur-xl p-2 shadow-2xl z-50 animate-fade-in">
|
||||
<div className="absolute bottom-full left-0 mb-2 w-60 lg:bottom-0 lg:left-full lg:ml-4 rounded-xl border border-border bg-card/95 backdrop-blur-xl p-2 shadow-2xl z-50 animate-fade-in">
|
||||
<div className="flex flex-col items-center justify-center p-3 mb-2 border-b border-border/50">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-gradient-to-tr from-primary to-blue-400 overflow-hidden mb-2">
|
||||
{user?.profile_picture ? (
|
||||
@@ -77,38 +114,45 @@ export default function SidebarProfile({ user, onOpenSettings }: ProfileProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-bold text-foreground text-center break-words w-full leading-tight">{user?.account_name || user?.username || 'Loading...'}</span>
|
||||
<span className="text-xs text-primary mt-1 text-center font-medium">{roleLabel}</span>
|
||||
<span className="text-sm font-bold text-foreground text-center break-words w-full leading-tight">
|
||||
{user?.account_name || user?.username || 'Loading...'}
|
||||
</span>
|
||||
{isViewAsMode ? (
|
||||
<span className="text-xs text-amber-400 mt-1 text-center font-medium flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" />
|
||||
{(() => {
|
||||
const vt = viewAsStatus?.view_as_type;
|
||||
if (vt === 'user') {
|
||||
const un = viewAsStatus?.user_name || 'Unknown';
|
||||
const ur = ROLE_DISPLAY_NAMES[viewAsStatus?.user_role] || viewAsStatus?.user_role?.replace(/_/g, ' ') || '';
|
||||
return `${un} (${ur})`;
|
||||
}
|
||||
return viewAsStatus?.agent_label || viewAsStatus?.agent_uuid || 'Unknown';
|
||||
})()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-primary mt-1 text-center font-medium">
|
||||
{getRoleDisplayName(user?.role)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAccountDropdownOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" /> Account Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
setIsAccountDropdownOpen(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4 shrink-0" /> : <Moon className="h-4 w-4 shrink-0" />}
|
||||
<span className="truncate">{theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}</span>
|
||||
</button>
|
||||
{!isViewAsMode && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsAccountDropdownOpen(false);
|
||||
onOpenSettings();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" /> Account Settings
|
||||
</button>
|
||||
)}
|
||||
<div className="h-px bg-border/50 my-1 mx-2" />
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('backone_view_as');
|
||||
}
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
router.push('/login');
|
||||
router.refresh();
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
className="w-full flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Activity } from "lucide-react";
|
||||
|
||||
interface SidebarSiteSelectorProps {
|
||||
user: { role?: string } | null;
|
||||
selectedSite: string;
|
||||
handleSiteSelect: (val: string) => void;
|
||||
}
|
||||
|
||||
export default function SidebarSiteSelector({
|
||||
user,
|
||||
selectedSite,
|
||||
handleSiteSelect,
|
||||
}: SidebarSiteSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
if (!user || !['SUPER_ADMIN', 'EXECUTIVE', 'SOC_ANALYST', 'ENGINEER'].includes(user.role || '')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getSiteLabel = (uuid: string) => {
|
||||
if (uuid === '6681452d_9cae_4ff4_8ae8_0d504774265e') return 'SIAB';
|
||||
if (uuid === '1959bb55_045b_47c7_bbdd_f33b7db197b9') return 'Office';
|
||||
return 'SIAB';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full px-2" ref={ref} style={{ fontFamily: "'Times New Roman', Times, serif" }}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 bg-white/[0.03] hover:bg-white/[0.08] border border-white/[0.08] hover:border-white/[0.15] px-3.5 py-2.5 rounded-xl transition-all duration-200 text-left cursor-pointer backdrop-blur-md",
|
||||
isOpen && "border-blue-500/30 ring-1 ring-blue-500/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-4 w-4 text-slate-400 shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 leading-none mb-1">
|
||||
ACTIVE SITE
|
||||
</span>
|
||||
<span className="text-xs font-medium text-slate-300 leading-none">
|
||||
{getSiteLabel(selectedSite)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-slate-600 transition-transform duration-300", isOpen && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-2 right-2 mt-1.5 rounded-xl border border-white/10 bg-slate-950/85 backdrop-blur-xl p-1.5 shadow-2xl z-50 origin-top transition-all duration-300 ease-out",
|
||||
isOpen
|
||||
? "opacity-100 scale-100 pointer-events-auto translate-y-0"
|
||||
: "opacity-0 scale-95 pointer-events-none -translate-y-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleSiteSelect('6681452d_9cae_4ff4_8ae8_0d504774265e');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
selectedSite === '6681452d_9cae_4ff4_8ae8_0d504774265e'
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
SIAB
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleSiteSelect('1959bb55_045b_47c7_bbdd_f33b7db197b9');
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
selectedSite === '1959bb55_045b_47c7_bbdd_f33b7db197b9'
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
Office
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export function SidebarSiteSwitcher({
|
||||
getSiteLabel,
|
||||
}: SidebarSiteSwitcherProps) {
|
||||
const isGlobalUser = user?.role === 'SUPER_ADMIN' ||
|
||||
user?.role === 'EXECUTIVE' ||
|
||||
((!user?.site_uuid || user?.site_uuid === 'default') && ['SOC_ANALYST', 'ENGINEER'].includes(user?.role || ''));
|
||||
|
||||
if (!user || !isGlobalUser) {
|
||||
@@ -67,15 +68,15 @@ export function SidebarSiteSwitcher({
|
||||
SIAB
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSiteSelect('d7902405_0dc2_458b_8584_ed4d24b64f24')}
|
||||
onClick={() => handleSiteSelect('1959bb55_045b_47c7_bbdd_f33b7db197b9')}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors",
|
||||
selectedSite === 'd7902405_0dc2_458b_8584_ed4d24b64f24'
|
||||
selectedSite === '1959bb55_045b_47c7_bbdd_f33b7db197b9'
|
||||
? "bg-primary/20 text-white font-medium"
|
||||
: "text-slate-400 hover:bg-white/5 hover:text-white"
|
||||
)}
|
||||
>
|
||||
Nexus
|
||||
Office
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDown, Calendar } from "lucide-react";
|
||||
import { useTimeFilter } from "@/contexts/TimeFilterContext";
|
||||
import { TIME_OPTIONS } from "./SidebarData";
|
||||
|
||||
export default function SidebarTimeSelector() {
|
||||
const { timeRange, setTimeRange } = useTimeFilter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const selectedTimeOption = TIME_OPTIONS.find(opt => opt.value === timeRange) || TIME_OPTIONS[3];
|
||||
|
||||
return (
|
||||
<div className="relative w-full px-2 pb-2" ref={ref} style={{ fontFamily: "'Times New Roman', Times, serif" }}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 bg-white/[0.03] hover:bg-white/[0.08] border border-white/[0.08] hover:border-white/[0.15] px-3.5 py-2.5 rounded-xl transition-all duration-200 text-left cursor-pointer backdrop-blur-md",
|
||||
isOpen && "border-blue-500/30 ring-1 ring-blue-500/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="h-4 w-4 text-slate-400 shrink-0" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500 leading-none mb-1">
|
||||
TIME FILTER
|
||||
</span>
|
||||
<span className="text-xs font-medium text-slate-300 leading-none">
|
||||
{selectedTimeOption.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={cn("h-4 w-4 text-slate-600 transition-transform duration-300", isOpen && "rotate-180")} />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-2 right-2 mt-1.5 rounded-xl border border-white/10 bg-slate-950/85 backdrop-blur-xl p-1.5 shadow-2xl z-50 origin-top transition-all duration-300 ease-out",
|
||||
isOpen
|
||||
? "opacity-100 scale-100 pointer-events-auto translate-y-0"
|
||||
: "opacity-0 scale-95 pointer-events-none -translate-y-2"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{TIME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setTimeRange(opt.value);
|
||||
setIsOpen(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, LogOut } from "lucide-react";
|
||||
import { stopViewAs } from "@/lib/admin-api";
|
||||
|
||||
interface ViewAsBannerProps {
|
||||
viewAs: {
|
||||
active: boolean;
|
||||
agent_label?: string;
|
||||
agent_uuid?: string;
|
||||
view_as_type?: string;
|
||||
user_name?: string;
|
||||
user_role?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ViewAsBanner({ viewAs }: ViewAsBannerProps) {
|
||||
if (!viewAs.active) return null;
|
||||
|
||||
const handleExit = async () => {
|
||||
await stopViewAs();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const viewAsType = viewAs.view_as_type;
|
||||
const agentLabel = viewAs.agent_label || viewAs.agent_uuid || 'Unknown';
|
||||
const agentUuid = viewAs.agent_uuid || '';
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-amber-600/90 via-amber-700/90 to-amber-900/90 border-b border-amber-500/30 px-4 sm:px-6 py-2.5 flex items-center justify-between text-white shadow-lg backdrop-blur-sm z-30">
|
||||
<div className="flex items-center gap-2 text-xs sm:text-sm font-medium min-w-0">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-300 animate-bounce shrink-0" />
|
||||
<span className="leading-relaxed truncate">
|
||||
{viewAsType === 'user' ? (
|
||||
<>
|
||||
<span className="text-amber-100/80">View-As Mode</span>
|
||||
{' — '}Viewing as <strong className="text-amber-200">{viewAs.user_name || 'Unknown'}</strong>{' '}
|
||||
<span className="text-xs font-normal text-white/70 bg-amber-950/40 px-1.5 py-0.5 rounded">
|
||||
{(viewAs.user_role || '').replace(/_/g, ' ')}
|
||||
</span>
|
||||
{agentLabel && agentLabel !== agentUuid && (
|
||||
<> via Agent <strong className="text-amber-200">{agentLabel}</strong></>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-amber-100/80">View-As Mode</span>
|
||||
{' — '}Viewing as Agent <strong className="text-amber-200">{agentLabel}</strong>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExit}
|
||||
className="flex items-center gap-1.5 px-3 py-1 bg-white/10 hover:bg-white/20 active:bg-white/30 border border-white/20 rounded-md text-xs font-semibold tracking-wide transition-all shadow-sm shrink-0 ml-2"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Exit View Mode</span>
|
||||
<span className="sm:hidden">Exit</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { useState, useEffect } from "react";
|
||||
import { useLookupApplications, useLookupCategories, LookupApp } from "@/lib/api";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect";
|
||||
import { Loader2, Search, AppWindow, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, X, Info, Tag, Box, Shield, Server } from "lucide-react";
|
||||
import { Loader2, Search, AppWindow, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||
import { ApplicationDetailModal } from "./ApplicationDetailModal";
|
||||
|
||||
export function ApplicationCatalog() {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
@@ -46,7 +47,6 @@ export function ApplicationCatalog() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 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" />
|
||||
@@ -72,7 +72,6 @@ export function ApplicationCatalog() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards Grid */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
@@ -148,7 +147,6 @@ export function ApplicationCatalog() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{pagination.total_pages > 1 && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 pt-4 border-t border-border/50 text-slate-400 text-sm animate-slide-up" style={{ animationDelay: '150ms' }}>
|
||||
<div>
|
||||
@@ -205,125 +203,11 @@ export function ApplicationCatalog() {
|
||||
</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()}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="px-6 pb-6 sm:px-8 sm:pb-8 relative -mt-16">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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?.replace(/^backone\./i, "") || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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?.replace(/^backone\./i, "")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ApplicationDetailModal
|
||||
app={selectedApp}
|
||||
isClosing={isClosingModal}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { LookupApp } from "@/lib/api";
|
||||
import { X, Info, Tag, Box, Shield, Server, AppWindow } from "lucide-react";
|
||||
|
||||
interface ApplicationDetailModalProps {
|
||||
app: LookupApp | null;
|
||||
isClosing: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ApplicationDetailModal({ app, isClosing, onClose }: ApplicationDetailModalProps) {
|
||||
if (!app) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6" onClick={onClose}>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||
/>
|
||||
<div
|
||||
className={`relative w-full max-w-2xl bg-card border border-border rounded-2xl shadow-2xl shadow-black/60 overflow-hidden ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="h-32 bg-gradient-to-br from-primary/20 via-primary/5 to-transparent relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
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>
|
||||
|
||||
<div className="px-6 pb-6 sm:px-8 sm:pb-8 relative -mt-16">
|
||||
<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">
|
||||
{app.logo || app.favicon ? (
|
||||
<img
|
||||
src={app.logo || app.favicon}
|
||||
alt={app.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: (app.logo || app.favicon) ? 'none' : 'block' }} />
|
||||
</div>
|
||||
|
||||
<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">{app.label}</h2>
|
||||
{app.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">
|
||||
{app.application_category.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{app.full_name && app.full_name !== app.label && (
|
||||
<p className="text-slate-400 font-medium">{app.full_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<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">
|
||||
{app.description || "No detailed description provided for this application or service."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">{app.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">{app.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">
|
||||
{app.tag?.replace(/^backone\./i, "") || 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{app.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">{app.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">
|
||||
{app.application_category.tag?.replace(/^backone\./i, "")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,16 +5,8 @@ import { useLookupCategories } from "@/lib/api";
|
||||
import { getViewAsHeaders } from "@/lib/admin-api";
|
||||
import { Card, CardContent } from "@/components/ui/Card";
|
||||
import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect";
|
||||
import { Loader2, ShieldAlert, Plus, Trash2, Globe, Tag, CheckCircle, AlertCircle } from "lucide-react";
|
||||
|
||||
interface BlacklistRule {
|
||||
_id: string;
|
||||
type: 'category' | 'domain';
|
||||
value: string;
|
||||
is_active: boolean;
|
||||
agent_uuid?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
import { Loader2, ShieldAlert, Plus, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { BlacklistRule, BlacklistRulesTable } from "./BlacklistRulesTable";
|
||||
|
||||
export function BlacklistConfiguration() {
|
||||
const [rules, setRules] = useState<BlacklistRule[]>([]);
|
||||
@@ -24,7 +16,6 @@ export function BlacklistConfiguration() {
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [userRole, setUserRole] = useState<string>("");
|
||||
|
||||
// Form states
|
||||
const [type, setType] = useState<'category' | 'domain'>('domain');
|
||||
const [customDomain, setCustomDomain] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("");
|
||||
@@ -52,7 +43,6 @@ export function BlacklistConfiguration() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchRules();
|
||||
// Fetch active user role to enforce RBAC in the frontend
|
||||
fetch('/api/auth/me', { headers: getViewAsHeaders() })
|
||||
.then(res => res.json())
|
||||
.then(d => {
|
||||
@@ -130,7 +120,6 @@ export function BlacklistConfiguration() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Informative Banner if User cannot edit */}
|
||||
{!canEdit && userRole && (
|
||||
<div className="flex items-start gap-3 p-4 bg-amber-950/20 border border-amber-500/30 rounded-xl text-amber-400 text-sm leading-relaxed animate-fade-in">
|
||||
<AlertCircle className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
@@ -149,7 +138,6 @@ export function BlacklistConfiguration() {
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column: Form to Add Blacklist */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
<Card className={`border-border/50 bg-card/60 backdrop-blur-xl transition-opacity duration-300 ${!canEdit ? 'opacity-60' : ''}`}>
|
||||
<CardContent className="p-6">
|
||||
@@ -252,69 +240,13 @@ export function BlacklistConfiguration() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Rules List */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Card className="border-border/50 bg-card/60 backdrop-blur-xl">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-bold text-white mb-4">Active Blacklist Rules</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="text-center p-16 text-slate-500 bg-secondary/10 border border-border/30 rounded-xl">
|
||||
<ShieldAlert className="w-10 h-10 mx-auto mb-2 text-slate-600" />
|
||||
<p className="font-semibold text-white/90">No blacklist rules configured</p>
|
||||
<p className="text-xs mt-1">Incoming flows matching blacklisted applications or categories will not trigger threats.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-sm text-slate-300">
|
||||
<thead className="bg-secondary/40 text-slate-400 font-mono text-[10px] uppercase tracking-wider border-b border-border/30">
|
||||
<tr>
|
||||
<th className="p-3 w-[15%] text-center">Type</th>
|
||||
<th className="p-3 w-[45%] text-center">Value</th>
|
||||
<th className="p-3 w-[25%] text-center">Agent</th>
|
||||
{canEdit && <th className="p-3 w-[15%] text-center">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule._id} className="hover:bg-secondary/10 transition-colors">
|
||||
<td className="p-3 text-center">
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded ${
|
||||
rule.type === 'category'
|
||||
? "bg-purple-950/40 text-purple-400 border border-purple-500/20"
|
||||
: "bg-blue-950/40 text-blue-400 border border-blue-500/20"
|
||||
}`}>
|
||||
{rule.type === 'category' ? <Tag className="w-3 h-3" /> : <Globe className="w-3 h-3" />}
|
||||
{rule.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-center font-mono text-white text-xs">{rule.value}</td>
|
||||
<td className="p-3 text-center font-mono text-slate-400 text-[11px] truncate max-w-[120px]" title={rule.agent_uuid}>
|
||||
{rule.agent_uuid || "-"}
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule._id)}
|
||||
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors"
|
||||
title="Delete Rule"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mx-auto" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<BlacklistRulesTable
|
||||
rules={rules}
|
||||
loading={loading}
|
||||
canEdit={canEdit}
|
||||
onDeleteRule={handleDeleteRule}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/Card";
|
||||
import { Loader2, ShieldAlert, Tag, Globe, Trash2 } from "lucide-react";
|
||||
|
||||
export interface BlacklistRule {
|
||||
_id: string;
|
||||
type: 'category' | 'domain';
|
||||
value: string;
|
||||
is_active: boolean;
|
||||
agent_uuid?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface BlacklistRulesTableProps {
|
||||
rules: BlacklistRule[];
|
||||
loading: boolean;
|
||||
canEdit: boolean;
|
||||
onDeleteRule: (id: string) => void;
|
||||
}
|
||||
|
||||
export function BlacklistRulesTable({ rules, loading, canEdit, onDeleteRule }: BlacklistRulesTableProps) {
|
||||
return (
|
||||
<Card className="border-border/50 bg-card/60 backdrop-blur-xl">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-bold text-white mb-4">Active Blacklist Rules</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-16">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="text-center p-16 text-slate-500 bg-secondary/10 border border-border/30 rounded-xl">
|
||||
<ShieldAlert className="w-10 h-10 mx-auto mb-2 text-slate-600" />
|
||||
<p className="font-semibold text-white/90">No blacklist rules configured</p>
|
||||
<p className="text-xs mt-1">Incoming flows matching blacklisted applications or categories will not trigger threats.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-sm text-slate-300">
|
||||
<thead className="bg-secondary/40 text-slate-400 font-mono text-[10px] uppercase tracking-wider border-b border-border/30">
|
||||
<tr>
|
||||
<th className="p-3 w-[15%] text-center">Type</th>
|
||||
<th className="p-3 w-[45%] text-center">Value</th>
|
||||
<th className="p-3 w-[25%] text-center">Agent</th>
|
||||
{canEdit && <th className="p-3 w-[15%] text-center">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/20">
|
||||
{rules.map((rule) => (
|
||||
<tr key={rule._id} className="hover:bg-secondary/10 transition-colors">
|
||||
<td className="p-3 text-center">
|
||||
<span className={`inline-flex items-center gap-1 text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded ${
|
||||
rule.type === 'category'
|
||||
? "bg-purple-950/40 text-purple-400 border border-purple-500/20"
|
||||
: "bg-blue-950/40 text-blue-400 border border-blue-500/20"
|
||||
}`}>
|
||||
{rule.type === 'category' ? <Tag className="w-3 h-3" /> : <Globe className="w-3 h-3" />}
|
||||
{rule.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-center font-mono text-white text-xs">{rule.value}</td>
|
||||
<td className="p-3 text-center font-mono text-slate-400 text-[11px] truncate max-w-[120px]" title={rule.agent_uuid}>
|
||||
{rule.agent_uuid || "-"}
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
onClick={() => onDeleteRule(rule._id)}
|
||||
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-950/30 rounded transition-colors"
|
||||
title="Delete Rule"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mx-auto" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -84,40 +84,40 @@ export function CategoryDetailPanel({
|
||||
|
||||
{/* 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"}`}
|
||||
className={`relative bg-card border border-border/50 rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 overflow-hidden font-sans ${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 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between px-4 py-4 sm:px-6 sm: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 className="p-2 sm:p-2.5 bg-white/[0.06] rounded-lg sm:rounded-xl ring-1 ring-blue-500/30 relative">
|
||||
<LayoutGrid className="w-4 h-4 sm:w-5 sm: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 className="min-w-0 flex-1">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest mb-0.5 text-blue-400">Traffic Category</p>
|
||||
<h3 className="text-base sm:text-lg font-bold text-card-foreground truncate" title={category}>{category}</h3>
|
||||
<p className="text-[10px] sm: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"
|
||||
className="self-end sm:self-auto ml-0 sm:ml-4 flex-shrink-0 p-1.5 sm:p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg sm:rounded-xl transition-all"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
<X className="h-4 sm:h-5 w-4 sm:w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Tabs ── */}
|
||||
<div className="flex border-b border-border bg-card/50 flex-shrink-0 px-4 pt-4">
|
||||
<div className="flex border-b border-border bg-card/50 flex-shrink-0 px-4 pt-4 overflow-x-auto no-scrollbar flex-nowrap select-none">
|
||||
<button
|
||||
onClick={() => setActiveTab("apps")}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors flex-shrink-0 ${
|
||||
activeTab === "apps"
|
||||
? "border-blue-500 text-blue-400"
|
||||
: "border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700"
|
||||
@@ -128,7 +128,7 @@ export function CategoryDetailPanel({
|
||||
</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 ${
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors flex-shrink-0 ${
|
||||
activeTab === "devices"
|
||||
? "border-blue-500 text-blue-400"
|
||||
: "border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-700"
|
||||
@@ -141,20 +141,20 @@ export function CategoryDetailPanel({
|
||||
|
||||
{/* ── 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">
|
||||
<div className="flex flex-wrap items-center gap-x-4 sm:gap-x-6 gap-y-1 px-4 py-2.5 sm:px-6 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" />
|
||||
<div className="h-3 w-px bg-white/10 hidden sm:block" />
|
||||
<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">
|
||||
<div className="h-3 w-px bg-white/10 hidden sm:block" />
|
||||
<span className="text-slate-500 text-[10px] uppercase tracking-wider w-full sm:w-auto">
|
||||
Sorted by bandwidth
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { DataTable } from "@/components/ui/DataTable";
|
||||
import { useRegions, useCities } from "@/lib/api-advanced";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
export function RoutingGeoTab() {
|
||||
const { data: regions, isLoading: loadingRegions } = useRegions();
|
||||
const { data: cities, isLoading: loadingCities } = useCities();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Regions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={regions}
|
||||
isLoading={loadingRegions}
|
||||
columns={[
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => fmtBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Top Cities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
data={cities}
|
||||
isLoading={loadingCities}
|
||||
columns={[
|
||||
{ header: "City", accessor: (row: any) => <span title={row.city_name}>{row.city_name}</span> },
|
||||
{ header: "Region", accessor: (row: any) => <span title={row.region_name}>{row.region_name}</span> },
|
||||
{ header: "Country", accessor: (row: any) => <span title={row.country_name}>{row.country_name}</span> },
|
||||
{ header: "Download", accessor: (row) => fmtBytes(row.download) },
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export function TlsCipherDetailModal({ cipher, onClose }: Props) {
|
||||
<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')}
|
||||
className={"relative bg-card border border-border/50 rounded-2xl w-full max-w-2xl flex flex-col shadow-2xl shadow-black/60 my-auto font-sans " + (isClosing ? 'animate-modal-out' : 'animate-modal-in')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -176,4 +176,4 @@ export function TlsCipherDetailModal({ cipher, onClose }: Props) {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -128,4 +128,4 @@ export default function TlsTables({ versionsData, ciphersData }: Props) {
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { useMemo, useState } from "react";
|
||||
import { SlidersHorizontal, X } from "lucide-react";
|
||||
import { ThreatStat } from "@/lib/api";
|
||||
import { formatAppLabel } from "@/lib/utils";
|
||||
import { DropdownFilterSelect } from "@/components/ui/DropdownFilterSelect";
|
||||
@@ -56,6 +55,8 @@ export function ThreatFilters({
|
||||
activeFilterCount,
|
||||
onReset,
|
||||
}: ThreatFiltersProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Derive unique options from data
|
||||
const opts = (key: keyof ThreatStat) => {
|
||||
const raw = Array.from(
|
||||
@@ -79,143 +80,161 @@ export function ThreatFilters({
|
||||
}, [all]);
|
||||
|
||||
return (
|
||||
<Card className="bg-card/80 backdrop-blur-xl border-border/50 relative z-30 overflow-visible">
|
||||
<CardHeader className="border-b border-border/50 pb-3 flex flex-row items-center justify-between">
|
||||
<div className="mb-4">
|
||||
{/* Compact Trigger Button Bar */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm font-semibold text-white uppercase tracking-wider">
|
||||
Filters
|
||||
</CardTitle>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center gap-2 px-3.5 py-2 rounded-xl bg-card/90 border border-border/70 hover:border-primary/50 text-xs font-semibold text-foreground transition-all shadow-sm active:scale-[0.98]"
|
||||
>
|
||||
<SlidersHorizontal className="w-3.5 h-3.5 text-primary" />
|
||||
<span>Filter Threat Data</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-primary/20 text-primary px-2 py-0.5 rounded-full text-[10px] font-bold">
|
||||
{activeFilterCount} active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{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 className="flex items-center gap-1.5 flex-wrap">
|
||||
{fType && fType !== "All" && (
|
||||
<span className="text-[10px] font-semibold bg-primary/10 text-primary border border-primary/20 px-2 py-1 rounded-lg flex items-center gap-1">
|
||||
Type: {fType}
|
||||
<button onClick={() => setFType("All")}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
)}
|
||||
{fSev && fSev !== "All" && (
|
||||
<span className="text-[10px] font-semibold bg-primary/10 text-primary border border-primary/20 px-2 py-1 rounded-lg flex items-center gap-1">
|
||||
Severity: {fSev}
|
||||
<button onClick={() => setFSev("All")}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
)}
|
||||
{fApp && fApp !== "All" && (
|
||||
<span className="text-[10px] font-semibold bg-primary/10 text-primary border border-primary/20 px-2 py-1 rounded-lg flex items-center gap-1">
|
||||
App: {formatAppLabel(fApp)}
|
||||
<button onClick={() => setFApp("All")}><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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"
|
||||
className="text-[11px] font-semibold text-red-400 hover:text-red-300 transition-colors flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-red-500/10"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Reset Filters
|
||||
<X className="w-3 h-3" />
|
||||
Reset All
|
||||
</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">
|
||||
{/* Date Range */}
|
||||
<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={(f, t) => {
|
||||
setDateFrom(f);
|
||||
setDateTo(t);
|
||||
}}
|
||||
onClear={() => {
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Type
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fType}
|
||||
onChange={setFType}
|
||||
options={opts("threat_type")}
|
||||
placeholder="All Types"
|
||||
/>
|
||||
</div>
|
||||
{/* Pop-up Filter Modal Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-overlay-in"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl bg-card/95 border border-border/80 rounded-3xl p-5 sm:p-6 shadow-2xl backdrop-blur-2xl animate-modal-in flex flex-col gap-5 max-h-[85vh] overflow-y-auto custom-scrollbar"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-4 h-4 text-primary" />
|
||||
<h3 className="text-sm sm:text-base font-bold text-foreground">Filter Detected Threats</h3>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="text-xs bg-primary/20 text-primary px-2.5 py-0.5 rounded-full font-bold">
|
||||
{activeFilterCount} Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1.5 text-muted-foreground hover:text-foreground rounded-full hover:bg-secondary transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Severity
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fSev}
|
||||
onChange={setFSev}
|
||||
options={opts("severity")}
|
||||
placeholder="All Severities"
|
||||
/>
|
||||
</div>
|
||||
{/* Modal Body Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">
|
||||
Date Range
|
||||
</label>
|
||||
<DateRangePicker
|
||||
minDate={minDate}
|
||||
dateFrom={dateFrom}
|
||||
dateTo={dateTo}
|
||||
onChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
|
||||
onClear={() => { setDateFrom(""); setDateTo(""); }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source IP */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Source IP
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fSrcIp}
|
||||
onChange={setFSrcIp}
|
||||
options={opts("ip_address")}
|
||||
placeholder="All Source IPs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">Type</label>
|
||||
<DropdownFilterSelect value={fType} onChange={setFType} options={opts("threat_type")} placeholder="All Types" />
|
||||
</div>
|
||||
|
||||
{/* Dest IP */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Dest IP
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fDstIp}
|
||||
onChange={setFDstIp}
|
||||
options={opts("dst_ip")}
|
||||
placeholder="All Dest IPs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">Severity</label>
|
||||
<DropdownFilterSelect value={fSev} onChange={setFSev} options={opts("severity")} placeholder="All Severities" />
|
||||
</div>
|
||||
|
||||
{/* MAC */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
MAC Address
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fMac}
|
||||
onChange={setFMac}
|
||||
options={opts("mac_address")}
|
||||
placeholder="All MACs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">Source IP</label>
|
||||
<DropdownFilterSelect value={fSrcIp} onChange={setFSrcIp} options={opts("ip_address")} placeholder="All Source IPs" />
|
||||
</div>
|
||||
|
||||
{/* App */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
App
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fApp}
|
||||
onChange={setFApp}
|
||||
options={opts("app_label")}
|
||||
placeholder="All Apps"
|
||||
optionFormatter={formatAppLabel}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">Dest IP</label>
|
||||
<DropdownFilterSelect value={fDstIp} onChange={setFDstIp} options={opts("dst_ip")} placeholder="All Dest IPs" />
|
||||
</div>
|
||||
|
||||
{/* Domain */}
|
||||
<div className="space-y-1.5 overflow-visible">
|
||||
<label className="text-[10px] text-slate-400 font-medium uppercase tracking-wider block">
|
||||
Domain
|
||||
</label>
|
||||
<DropdownFilterSelect
|
||||
value={fDomain}
|
||||
onChange={setFDomain}
|
||||
options={opts("domain")}
|
||||
placeholder="All Domains"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">MAC Address</label>
|
||||
<DropdownFilterSelect value={fMac} onChange={setFMac} options={opts("mac_address")} placeholder="All MACs" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">App</label>
|
||||
<DropdownFilterSelect value={fApp} onChange={setFApp} options={opts("app_label")} placeholder="All Apps" optionFormatter={formatAppLabel} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<label className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider block">Domain</label>
|
||||
<DropdownFilterSelect value={fDomain} onChange={setFDomain} options={opts("domain")} placeholder="All Domains" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border/50 pt-4 mt-2">
|
||||
{activeFilterCount > 0 ? (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="text-xs font-semibold text-red-400 hover:text-red-300 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Reset All Filters
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="px-5 py-2.5 rounded-xl bg-primary text-white text-xs font-bold hover:bg-primary/90 transition-all shadow-md shadow-primary/20"
|
||||
>
|
||||
Apply Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -200,4 +200,4 @@ export function ThreatRecommendationsDrawer({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -132,20 +132,33 @@ export default function ThreatsContent() {
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
|
||||
Detected Threats
|
||||
{threats.isLoading && <Loader2 className="w-5 h-5 animate-spin text-primary" />}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">Monitor and respond to network security threats.</p>
|
||||
<div className="space-y-6 sm:space-y-8 animate-fade-in pb-10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 sm:justify-start sm:gap-4 w-full">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-white flex items-center gap-2 sm:gap-3 truncate">
|
||||
<span>Detected Threats</span>
|
||||
{threats.isLoading && <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 animate-spin text-primary flex-shrink-0" />}
|
||||
</h2>
|
||||
{/* Mobile Actions: Inline next to Title */}
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 sm:hidden">
|
||||
<HelpTrigger pageId="threats" />
|
||||
<div className="flex items-center justify-center text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 p-1.5 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]" title="Active Monitoring">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1 leading-normal sm:leading-relaxed">
|
||||
Monitor and respond to network security threats.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* Laptop/Tablet Actions: Floats on the right */}
|
||||
<div className="hidden sm:flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
<HelpTrigger pageId="threats" />
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
<span>Active Monitoring</span>
|
||||
<div className="flex items-center justify-center text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-3 py-1.5 rounded-lg shadow-[0_0_15px_rgba(16,185,129,0.1)]">
|
||||
<ShieldCheck className="h-3.5 w-3.5 text-emerald-400 mr-1.5" />
|
||||
<span className="text-xs font-semibold">Active Monitoring</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -158,4 +158,4 @@ export function getMitigationSteps(type: string): { intro: string; steps: Mitiga
|
||||
]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,4 +33,4 @@
|
||||
|
||||
return infoA.portNum - infoB.portNum;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -82,22 +82,22 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
className={`relative bg-card border border-border/50 rounded-2xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 font-sans ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border bg-gradient-to-r from-blue-900/30 to-purple-900/20 rounded-t-2xl flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-500/20 rounded-lg ring-1 ring-blue-500/30">
|
||||
<Server className="w-5 h-5 text-blue-400" />
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between px-4 py-4 sm:px-6 sm: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="p-1.5 sm:p-2 bg-blue-500/20 rounded-lg ring-1 ring-blue-500/30 flex-shrink-0">
|
||||
<Server className="w-4 h-4 sm:w-5 sm:h-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-card-foreground">{agentLabel}</h3>
|
||||
<p className="text-xs text-muted-foreground font-mono">{agentUuid}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base sm:text-lg font-bold text-card-foreground truncate">{agentLabel}</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground font-mono truncate">{agentUuid}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleClose} className="p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all">
|
||||
<X className="w-5 h-5" />
|
||||
<button onClick={handleClose} className="p-1.5 sm:p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg transition-all self-end sm:self-auto">
|
||||
<X className="w-4.5 h-4.5 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -112,36 +112,36 @@ export function AgentDetailModal({ agentUuid, agentLabel, onClose }: Props) {
|
||||
) : data ? (
|
||||
<>
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-6 py-4 border-b border-border/50 flex-shrink-0">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 px-4 py-3 sm:px-6 sm:py-4 border-b border-border/50 flex-shrink-0">
|
||||
{[
|
||||
{ label: "Devices", value: data.summary?.total_devices || data.devices.length, color: "text-card-foreground" },
|
||||
{ label: "Flows", value: data.summary?.active_flows || data.flows.length, color: "text-card-foreground" },
|
||||
{ label: "Download", value: fmtBytes(data.summary?.bandwidth_down || data.devices.reduce((acc, d) => acc + (d.download ?? 0), 0)), color: "text-cyan-400" },
|
||||
{ label: "Upload", value: fmtBytes(data.summary?.bandwidth_up || data.devices.reduce((acc, d) => acc + (d.upload ?? 0), 0)), color: "text-green-400" },
|
||||
].map((kpi) => (
|
||||
<div key={kpi.label} className="bg-secondary rounded-xl p-3 border border-border/50">
|
||||
<p className="text-xs text-muted-foreground mb-1">{kpi.label}</p>
|
||||
<p className={`text-xl font-bold ${kpi.color}`}>{kpi.value}</p>
|
||||
<div key={kpi.label} className="bg-secondary rounded-xl p-2.5 sm:p-3 border border-border/50">
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-0.5 sm:mb-1">{kpi.label}</p>
|
||||
<p className={`text-base sm:text-xl font-bold ${kpi.color}`}>{kpi.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 px-6 py-3 border-b border-border/50 flex-shrink-0 flex-wrap">
|
||||
<div className="flex gap-1.5 px-4 sm:px-6 py-2 sm:py-3 border-b border-border/50 flex-shrink-0 overflow-x-auto flex-nowrap no-scrollbar select-none">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all flex-shrink-0 ${
|
||||
activeTab === tab.key
|
||||
? "bg-blue-500/20 text-blue-300 ring-1 ring-blue-500/40"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-secondary"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
<span>{tab.label}</span>
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`ml-0.5 px-1.5 py-0.5 rounded text-xs ${activeTab === tab.key ? "bg-blue-500/30 text-blue-200" : "bg-white/10 text-slate-300"}`}>
|
||||
<span className={`ml-0.5 px-1.5 py-0.5 rounded text-[10px] ${activeTab === tab.key ? "bg-blue-500/30 text-blue-200" : "bg-white/10 text-slate-300"}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -93,7 +93,7 @@ export function AppDetailModal({ appLabel, favicon, category, agentUuid, onClose
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-card border border-border rounded-2xl w-full max-w-3xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
className={`relative bg-card border border-border/50 rounded-2xl w-full max-w-3xl h-[85vh] flex flex-col shadow-2xl shadow-black/60 font-sans ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "red" | "orange" | "yellow" | "green" | "blue" | "cyan";
|
||||
}
|
||||
|
||||
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
"border-primary/20 bg-primary/10 text-primary": variant === "default",
|
||||
"border-red-500/20 bg-red-500/10 text-red-500": variant === "red",
|
||||
"border-orange-500/20 bg-orange-500/10 text-orange-500": variant === "orange",
|
||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-500": variant === "yellow",
|
||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-500": variant === "green",
|
||||
"border-blue-500/20 bg-blue-500/10 text-blue-500": variant === "blue",
|
||||
"border-cyan-500/20 bg-cyan-500/10 text-cyan-500": variant === "cyan",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: "default" | "red" | "orange" | "yellow" | "green" | "blue" | "cyan";
|
||||
}
|
||||
|
||||
export function Badge({ className, variant = "default", ...props }: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
"border-primary/20 bg-primary/10 text-primary": variant === "default",
|
||||
"border-red-500/20 bg-red-500/10 text-red-500": variant === "red",
|
||||
"border-orange-500/20 bg-orange-500/10 text-orange-500": variant === "orange",
|
||||
"border-yellow-500/20 bg-yellow-500/10 text-yellow-500": variant === "yellow",
|
||||
"border-emerald-500/20 bg-emerald-500/10 text-emerald-500": variant === "green",
|
||||
"border-blue-500/20 bg-blue-500/10 text-blue-500": variant === "blue",
|
||||
"border-cyan-500/20 bg-cyan-500/10 text-cyan-500": variant === "cyan",
|
||||
},
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+68
-68
@@ -1,68 +1,68 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card/80 backdrop-blur-xl text-card-foreground shadow-sm hover:shadow-primary/5 hover:border-primary/20 transition-all duration-300 ease-in-out hover:-translate-y-0.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col space-y-1.5 p-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("p-6 pt-0", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card/80 backdrop-blur-xl text-card-foreground shadow-sm hover:shadow-primary/5 hover:border-primary/20 transition-all duration-300 ease-in-out hover:-translate-y-0.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col space-y-1.5 p-4 sm:p-6", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
"font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("p-4 sm:p-6 pt-0 sm:pt-0", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import { useState, useRef, useEffect } from "react";
|
||||
import { Search, Download, ChevronDown, FileText, FileSpreadsheet } from "lucide-react";
|
||||
import { CsvExportOptions, downloadCsv } from "./DataTableCsvHelper";
|
||||
import { DataTablePagination } from "./DataTablePagination";
|
||||
import { DataTableMobileCards } from "./DataTableMobileCards";
|
||||
|
||||
import { DataTableDesktopView } from "./DataTableDesktopView";
|
||||
|
||||
export interface Column<T> {
|
||||
header: React.ReactNode;
|
||||
@@ -155,100 +158,37 @@ export function DataTable<T>({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card/50 backdrop-blur-xl shadow-sm overflow-hidden">
|
||||
<div className="w-full max-h-[400px] overflow-y-auto custom-scrollbar [&::-webkit-scrollbar-track]:mt-[48px]">
|
||||
<table className="w-full text-sm text-center table-fixed">
|
||||
<thead className="bg-slate-900 text-muted-foreground border-b border-border/50 sticky top-0 z-20">
|
||||
<tr>
|
||||
{columns.map((col, i) => {
|
||||
const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-'));
|
||||
const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` };
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
onClick={() => col.sortable && handleSort(i)}
|
||||
className={`px-4 py-3.5 h-[48px] font-medium tracking-wide text-center ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-black/5 dark:hover:bg-white/5 transition-colors group select-none" : ""}`}
|
||||
style={widthStyle}
|
||||
>
|
||||
<div className="flex items-center gap-1 justify-center">
|
||||
{col.header}
|
||||
{col.sortable && (
|
||||
<div className="flex flex-col text-[10px] opacity-40 group-hover:opacity-100 transition-opacity">
|
||||
<span className={`${sortConfig?.key === i && sortConfig.direction === 'asc' ? 'text-primary opacity-100' : ''}`}>▲</span>
|
||||
<span className={`-mt-1 ${sortConfig?.key === i && sortConfig.direction === 'desc' ? 'text-primary opacity-100' : ''}`}>▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-3 animate-pulse">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl"></div>
|
||||
<svg className="animate-spin relative h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium tracking-wide">Loading data...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Search className="h-8 w-8 mb-3 opacity-20" />
|
||||
<span className="text-sm font-medium">No data found</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedData.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`hover:bg-primary/5 border-b border-border/50 transition-colors duration-200 group h-[48px] ${onRowClick ? "cursor-pointer hover:bg-primary/10" : ""} ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
>
|
||||
{columns.map((col, j) => {
|
||||
const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-'));
|
||||
const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` };
|
||||
const content = col.accessor(row, (activePage - 1) * pageSize + i);
|
||||
const tooltipText = col.tooltip
|
||||
? col.tooltip(row)
|
||||
: (typeof content === 'string' || typeof content === 'number' ? String(content) : undefined);
|
||||
return (
|
||||
<td
|
||||
key={j}
|
||||
className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground text-center transition-colors ${col.className || ""}`}
|
||||
style={widthStyle}
|
||||
title={tooltipText}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Card View (< 768px) */}
|
||||
<DataTableMobileCards
|
||||
data={paginatedData}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
startIndex={startIndex}
|
||||
onRowClick={onRowClick}
|
||||
getRowClassName={getRowClassName}
|
||||
/>
|
||||
|
||||
<DataTablePagination
|
||||
totalItems={totalItems}
|
||||
pageSize={pageSize}
|
||||
currentPage={activePage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setActivePage}
|
||||
/>
|
||||
</div>
|
||||
{/* Desktop / Tablet Table View (>= 768px) */}
|
||||
<DataTableDesktopView
|
||||
data={paginatedData}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
activePage={activePage}
|
||||
pageSize={pageSize}
|
||||
sortConfig={sortConfig}
|
||||
onSort={handleSort}
|
||||
onRowClick={onRowClick}
|
||||
getRowClassName={getRowClassName}
|
||||
/>
|
||||
|
||||
<DataTablePagination
|
||||
totalItems={totalItems}
|
||||
pageSize={pageSize}
|
||||
currentPage={activePage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setActivePage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { Column } from "./DataTable";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface DataTableDesktopViewProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
isLoading?: boolean;
|
||||
activePage: number;
|
||||
pageSize: number;
|
||||
sortConfig: { key: number; direction: 'asc' | 'desc' } | null;
|
||||
onSort: (key: number) => void;
|
||||
onRowClick?: (row: T) => void;
|
||||
getRowClassName?: (row: T) => string;
|
||||
}
|
||||
|
||||
export function DataTableDesktopView<T>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
activePage,
|
||||
pageSize,
|
||||
sortConfig,
|
||||
onSort,
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
}: DataTableDesktopViewProps<T>) {
|
||||
return (
|
||||
<div className="hidden md:block rounded-xl border border-border bg-[#0B0F19] shadow-sm overflow-hidden overflow-x-auto">
|
||||
<table className="w-full text-sm text-center table-fixed border-collapse">
|
||||
<thead className="bg-slate-900 border-b border-border/80">
|
||||
<tr className="bg-slate-900">
|
||||
{columns.map((col, i) => {
|
||||
const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-'));
|
||||
const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` };
|
||||
return (
|
||||
<th
|
||||
key={i}
|
||||
onClick={() => col.sortable && onSort(i)}
|
||||
className={`px-4 py-3.5 h-[48px] font-semibold text-xs tracking-wider text-slate-300 bg-slate-900 border-b border-border/80 text-center ${col.className || ""} ${col.sortable ? "cursor-pointer hover:bg-slate-800/80 transition-colors group select-none" : ""}`}
|
||||
style={widthStyle}
|
||||
>
|
||||
<div className="flex items-center gap-1 justify-center">
|
||||
{col.header}
|
||||
{col.sortable && (
|
||||
<div className="flex flex-col text-[10px] opacity-40 group-hover:opacity-100 transition-opacity">
|
||||
<span className={`${sortConfig?.key === i && sortConfig.direction === 'asc' ? 'text-primary opacity-100' : ''}`}>▲</span>
|
||||
<span className={`-mt-1 ${sortConfig?.key === i && sortConfig.direction === 'desc' ? 'text-primary opacity-100' : ''}`}>▼</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/50">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-muted-foreground">
|
||||
<div className="flex flex-col items-center justify-center gap-3 animate-pulse">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-primary/20 rounded-full blur-xl"></div>
|
||||
<svg className="animate-spin relative h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-sm font-medium tracking-wide">Loading data...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Search className="h-8 w-8 mb-3 opacity-20" />
|
||||
<span className="text-sm font-medium">No data found</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`hover:bg-primary/5 border-b border-border/50 transition-colors duration-200 group h-[48px] ${onRowClick ? "cursor-pointer hover:bg-primary/10" : ""} ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||
>
|
||||
{columns.map((col, j) => {
|
||||
const hasWidth = col.className?.split(' ').some(c => c.startsWith('w-[') || c.startsWith('w-'));
|
||||
const widthStyle = hasWidth ? undefined : { width: `${100 / columns.length}%` };
|
||||
const content = col.accessor(row, (activePage - 1) * pageSize + i);
|
||||
const tooltipText = col.tooltip
|
||||
? col.tooltip(row)
|
||||
: (typeof content === 'string' || typeof content === 'number' ? String(content) : undefined);
|
||||
return (
|
||||
<td
|
||||
key={j}
|
||||
className={`px-2 py-3 h-[48px] max-h-[48px] overflow-hidden text-foreground text-center transition-colors ${col.className || ""}`}
|
||||
style={widthStyle}
|
||||
title={tooltipText}
|
||||
>
|
||||
{content}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Column } from "./DataTable";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableMobileCardsProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
isLoading?: boolean;
|
||||
startIndex: number;
|
||||
onRowClick?: (row: T) => void;
|
||||
getRowClassName?: (row: T) => string;
|
||||
}
|
||||
|
||||
export function DataTableMobileCards<T>({
|
||||
data,
|
||||
columns,
|
||||
isLoading,
|
||||
startIndex,
|
||||
onRowClick,
|
||||
getRowClassName,
|
||||
}: DataTableMobileCardsProps<T>) {
|
||||
// Main title column (IP, Name, Label, App)
|
||||
const mainHeader = useMemo(() => {
|
||||
return (
|
||||
columns.find((c) => {
|
||||
const h = String(c.header || "").toLowerCase();
|
||||
return (
|
||||
h.includes("ip") ||
|
||||
h.includes("agent") ||
|
||||
h.includes("app") ||
|
||||
h.includes("label") ||
|
||||
h.includes("name")
|
||||
);
|
||||
}) ||
|
||||
columns[1] ||
|
||||
columns[0]
|
||||
);
|
||||
}, [columns]);
|
||||
|
||||
// Status / Severity / Risk badge column to pin to top right
|
||||
const statusHeader = useMemo(() => {
|
||||
return columns.find((c) => {
|
||||
const h = String(c.header || "").toLowerCase();
|
||||
return (
|
||||
h.includes("status") ||
|
||||
h.includes("state") ||
|
||||
h.includes("risk") ||
|
||||
h.includes("severity") ||
|
||||
h.includes("level")
|
||||
);
|
||||
});
|
||||
}, [columns]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="md:hidden p-6 text-center text-slate-400 bg-slate-900/60 border border-slate-800 rounded-2xl animate-pulse text-xs">
|
||||
Loading data...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="md:hidden p-6 text-center text-slate-400 bg-slate-900/60 border border-slate-800 rounded-2xl text-xs font-medium">
|
||||
No data found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="md:hidden space-y-3.5 touch-pan-y pb-20">
|
||||
{data.map((row, i) => {
|
||||
const rowIndex = startIndex + i;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={cn(
|
||||
"bg-slate-900/90 border border-slate-800/80 rounded-2xl p-4 flex flex-col gap-3 shadow-lg backdrop-blur-md hover:border-blue-500/30 active:bg-slate-800/60 transition-all duration-150 touch-pan-y",
|
||||
onRowClick && "cursor-pointer active:scale-[0.99]",
|
||||
getRowClassName ? getRowClassName(row) : ""
|
||||
)}
|
||||
>
|
||||
{/* Header Row: Index + Primary Title on Left, Status Badge on Right */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800/70 pb-3 gap-2.5 min-w-0">
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<span className="text-[10px] font-bold text-slate-300 font-mono shrink-0 bg-slate-800/90 px-2 py-0.5 rounded-md border border-slate-700/60 shadow-sm">
|
||||
#{rowIndex + 1}
|
||||
</span>
|
||||
<div className="text-sm font-bold text-blue-400 truncate tracking-tight">
|
||||
{mainHeader ? mainHeader.accessor(row, rowIndex) : null}
|
||||
</div>
|
||||
</div>
|
||||
{statusHeader && (
|
||||
<div className="shrink-0 text-xs font-medium">
|
||||
{statusHeader.accessor(row, rowIndex)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Structured Card Grid Items */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3 text-xs">
|
||||
{columns.map((col, j) => {
|
||||
if (col === mainHeader || col === statusHeader) return null;
|
||||
const headerText =
|
||||
typeof col.header === "string" || typeof col.header === "number"
|
||||
? String(col.header)
|
||||
: "";
|
||||
if (headerText === "#" || headerText.trim() === "#") return null;
|
||||
|
||||
const val = col.accessor(row, rowIndex);
|
||||
if (val == null || val === "-") return null;
|
||||
|
||||
const lowerH = headerText.toLowerCase();
|
||||
const isFullWidth =
|
||||
lowerH.includes("mac") ||
|
||||
lowerH.includes("flow id") ||
|
||||
lowerH.includes("domain") ||
|
||||
lowerH.includes("url") ||
|
||||
lowerH.includes("action") ||
|
||||
lowerH.includes("mitigation") ||
|
||||
lowerH.includes("description") ||
|
||||
lowerH.includes("device") ||
|
||||
lowerH.includes("vendor") ||
|
||||
lowerH.includes("manufacturer") ||
|
||||
lowerH.includes("agent") ||
|
||||
lowerH.includes("probe");
|
||||
|
||||
const tooltip = col.tooltip ? col.tooltip(row) : undefined;
|
||||
|
||||
if (isFullWidth) {
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className="col-span-2 flex flex-col min-w-0 bg-slate-950/60 border border-slate-800/60 rounded-xl p-2.5"
|
||||
title={tooltip}
|
||||
>
|
||||
{headerText && (
|
||||
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider mb-1">
|
||||
{headerText}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-slate-200 font-medium break-all leading-relaxed">
|
||||
{val}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className="col-span-1 flex flex-col min-w-0 justify-center bg-slate-950/30 border border-slate-800/40 rounded-xl p-2.5"
|
||||
title={tooltip}
|
||||
>
|
||||
{headerText && (
|
||||
<div className="text-[10px] text-slate-400 font-semibold uppercase tracking-wider truncate mb-1">
|
||||
{headerText}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-slate-200 font-medium min-w-0 overflow-hidden text-ellipsis leading-tight">
|
||||
{val}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -144,4 +144,4 @@ export function DateRangePicker({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export default function DeviceAppsTab({ apps, totalDownload, totalUpload }: Prop
|
||||
|
||||
if (apps.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-border/50 rounded-2xl">
|
||||
<Box 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 Application Traffic</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
@@ -91,7 +91,7 @@ export default function DeviceAppsTab({ apps, totalDownload, totalUpload }: Prop
|
||||
const appBytes = app.download + app.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 key={i} className="bg-white/[0.03] border border-border/50 p-4 rounded-2xl 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">
|
||||
{formatAppLabel(app.app_label)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import DeviceDomainsTab from "./DeviceDomainsTab";
|
||||
import DeviceFlowsTab from "./DeviceFlowsTab";
|
||||
import DeviceThreatsTab from "./DeviceThreatsTab";
|
||||
import DeviceIdentityCard from "./DeviceIdentityCard";
|
||||
import DeviceKpiSection from "./DeviceKpiSection";
|
||||
|
||||
interface Props {
|
||||
ip: string;
|
||||
@@ -96,57 +97,59 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
<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'}`}
|
||||
className={`relative bg-card border border-border/50 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 font-sans ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-border bg-gradient-to-br from-blue-500/5 via-slate-500/5 to-slate-500/10 dark:from-blue-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="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between px-4 py-4 sm:px-6 sm:py-5 border-b border-border bg-gradient-to-br from-blue-500/5 via-slate-500/5 to-slate-500/10 dark:from-blue-900/10 dark:via-slate-900/20 dark:to-slate-950/40 relative z-10">
|
||||
<div className="flex items-center gap-3 sm:gap-4 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-3 bg-gradient-to-br from-blue-500/20 to-cyan-500/10 rounded-xl ring-1 ring-blue-500/30 relative">
|
||||
<Monitor className="w-6 h-6 text-blue-500 dark:text-blue-400" />
|
||||
<div className="p-2 sm:p-3 bg-gradient-to-br from-blue-500/20 to-cyan-500/10 rounded-lg sm:rounded-xl ring-1 ring-blue-500/30 relative">
|
||||
<Monitor className="w-5 h-5 sm:w-6 sm:h-6 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-card-foreground tracking-tight">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<h3 className="text-base sm:text-xl font-bold text-card-foreground tracking-tight truncate max-w-full">
|
||||
{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" />}
|
||||
{isRefreshing && <RefreshCcw className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-blue-500 dark:text-blue-400 animate-spin flex-shrink-0" />}
|
||||
</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">{mac || data?.mac_address || deviceData?.mac_address || 'No MAC'}</span>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-0.5 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
<span className="truncate max-w-[150px] sm:max-w-none">{data?.device_label || deviceData?.device_label || 'Unknown Device'}</span>
|
||||
<span className="hidden sm:inline text-border">•</span>
|
||||
<span className="font-mono text-[10px] sm:text-xs bg-muted/60 dark:bg-slate-800/80 px-1.5 py-0.5 rounded leading-none">{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">
|
||||
<Server className="w-3.5 h-3.5" />
|
||||
Source Agent: <strong>{data.agent_label}</strong> <span className="text-muted-foreground/60">({data.agent_uuid})</span>
|
||||
<p className="text-[10px] sm:text-xs text-blue-600 dark:text-blue-400 mt-1 flex flex-wrap items-center gap-1">
|
||||
<Server className="w-3 h-3 flex-shrink-0" />
|
||||
<span>Source Agent:</span>
|
||||
<strong className="bg-blue-500/10 px-1 py-0.5 rounded">{data.agent_label}</strong>
|
||||
<span className="text-muted-foreground/60 truncate max-w-[100px] sm:max-w-none">({data.agent_uuid})</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{data && (
|
||||
<div className="flex items-center justify-between sm:justify-end gap-3 flex-shrink-0 w-full sm:w-auto border-t border-border/40 pt-3 sm:border-t-0 sm:pt-0">
|
||||
{data ? (
|
||||
<button
|
||||
disabled={isExporting}
|
||||
onClick={exportToPdf}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-lg sm:rounded-xl text-xs font-semibold bg-blue-600 hover:bg-blue-500 text-white transition-all shadow-md shadow-blue-600/15 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
<Loader2 className="w-3 h-3 sm:w-3.5 sm:h-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToLine className="w-3.5 h-3.5" />
|
||||
<ArrowDownToLine className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||
)}
|
||||
Export PDF
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleClose} className="p-2.5 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-xl transition-all">
|
||||
<X className="w-5 h-5" />
|
||||
) : <div />}
|
||||
<button onClick={handleClose} className="p-1.5 sm:p-2 text-muted-foreground hover:text-card-foreground hover:bg-secondary rounded-lg sm:rounded-xl transition-all">
|
||||
<X className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,7 +172,7 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-border overflow-x-hidden custom-scrollbar flex-shrink-0 bg-secondary/30 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 px-4 sm:px-6 py-2 sm:py-3 border-b border-border overflow-x-auto flex-nowrap bg-secondary/30 flex-shrink-0 no-scrollbar select-none">
|
||||
{tabs.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const isActive = activeTab === t.id;
|
||||
@@ -177,14 +180,14 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setActiveTab(t.id)}
|
||||
className={`flex items-center gap-2.5 px-4 py-2 rounded-xl text-sm font-semibold transition-all duration-300 ${
|
||||
className={`flex items-center gap-1.5 sm:gap-2.5 px-3 py-1.5 sm:px-4 sm:py-2 rounded-lg sm:rounded-xl text-xs sm:text-sm font-semibold transition-all duration-300 flex-shrink-0 ${
|
||||
isActive
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-300 border border-blue-500/30"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${isActive ? 'text-blue-600 dark:text-blue-400' : ''}`} />
|
||||
{t.label}
|
||||
<Icon className={`w-3.5 h-3.5 sm:w-4 sm:h-4 ${isActive ? 'text-blue-600 dark:text-blue-400' : ''}`} />
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -196,26 +199,7 @@ export function DeviceDetailModal({ ip, mac = '', deviceData, onClose }: Props)
|
||||
<div className="space-y-6 animate-fade-in text-xs">
|
||||
<ActiveDurationDisplay firstSeen={data.first_seen || undefined} lastSeen={data.last_seen || undefined} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-6 bg-gradient-to-br from-cyan-500/5 dark:from-cyan-500/10 to-transparent border border-cyan-100 dark:border-cyan-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowDownToLine className="w-16 h-16 text-cyan-500 dark:text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4" /> Total Download
|
||||
</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-br from-emerald-500/5 dark:from-emerald-500/10 to-transparent border border-emerald-100 dark:border-emerald-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<ArrowUpFromLine className="w-16 h-16 text-emerald-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowUpFromLine className="w-4 h-4" /> Total Upload
|
||||
</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<DeviceKpiSection totalDownload={data.total_download} totalUpload={data.total_upload} />
|
||||
|
||||
<DeviceIdentityCard
|
||||
osLabel={data.os_label || deviceData?.os_label || ""}
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function DeviceDomainsTab({ domains, totalDownload, totalUpload }
|
||||
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-border/50 rounded-2xl">
|
||||
<Globe 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 Web/Domain Traffic</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
@@ -91,7 +91,7 @@ export default function DeviceDomainsTab({ domains, totalDownload, totalUpload }
|
||||
const appBytes = dom.download + dom.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 key={i} className="bg-white/[0.03] border border-border/50 p-4 rounded-2xl hover:bg-white/[0.05] transition-colors group relative overflow-hidden text-xs">
|
||||
<div className="absolute top-0 left-0 h-1 bg-purple-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={dom.app_label}>
|
||||
<span className="truncate">{dom.app_label}</span>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function DeviceFlowsTab({ flows, ip }: Props) {
|
||||
|
||||
if (flows.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-border/50 rounded-2xl">
|
||||
<Activity 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 Flow Data Yet</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function DeviceIdentityCard({
|
||||
lastSeen,
|
||||
}: DeviceIdentityCardProps) {
|
||||
return (
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6 text-xs">
|
||||
<div className="bg-white/[0.02] border border-border/50 rounded-2xl p-6 text-xs">
|
||||
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" /> Identity & Metadata
|
||||
</h4>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface DeviceKpiSectionProps {
|
||||
totalDownload: number;
|
||||
totalUpload: number;
|
||||
}
|
||||
|
||||
export default function DeviceKpiSection({ totalDownload, totalUpload }: DeviceKpiSectionProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 sm: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-12 h-12 sm:w-16 sm:h-16 text-cyan-500 dark:text-cyan-400" />
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm font-medium text-cyan-600 dark:text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> Total Download
|
||||
</p>
|
||||
<p className="text-2xl sm:text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(totalDownload)}</p>
|
||||
</div>
|
||||
<div className="p-4 sm: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-12 h-12 sm:w-16 sm:h-16 text-emerald-500 dark:text-emerald-400" />
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm font-medium text-emerald-600 dark:text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowUpFromLine className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> Total Upload
|
||||
</p>
|
||||
<p className="text-2xl sm:text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(totalUpload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,27 +17,27 @@ export default function DeviceOverviewTab({ data, deviceData }: DeviceOverviewTa
|
||||
<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="p-6 bg-gradient-to-br from-cyan-500/10 to-transparent border 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" />
|
||||
<ArrowDownToLine className="w-16 h-16 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">
|
||||
<p className="text-sm font-medium text-cyan-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4" /> Total Download
|
||||
</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_download)}</p>
|
||||
</div>
|
||||
<div className="p-6 bg-gradient-to-br from-emerald-500/5 dark:from-emerald-500/10 to-transparent border border-emerald-100 dark:border-emerald-500/20 rounded-2xl relative overflow-hidden group">
|
||||
<div className="p-6 bg-gradient-to-br from-emerald-500/10 to-transparent border 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" />
|
||||
<ArrowUpFromLine className="w-16 h-16 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">
|
||||
<p className="text-sm font-medium text-emerald-400/80 mb-2 flex items-center gap-2">
|
||||
<ArrowUpFromLine className="w-4 h-4" /> Total Upload
|
||||
</p>
|
||||
<p className="text-4xl font-black text-card-foreground tracking-tight">{fmtBytes(data.total_upload)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6">
|
||||
<div className="bg-white/[0.02] border border-border/50 rounded-2xl p-6">
|
||||
<h4 className="text-sm font-bold text-card-foreground uppercase tracking-wider mb-6 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-500" /> Identity & Metadata
|
||||
</h4>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function DeviceProtocolsTab({ protocols, totalDownload, totalUplo
|
||||
|
||||
if (protocols.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl">
|
||||
<div className="text-center py-10 bg-white/[0.02] border border-border/50 rounded-2xl">
|
||||
<Link2 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 Protocol Data</h4>
|
||||
<p className="text-muted-foreground font-medium max-w-md mx-auto">
|
||||
@@ -91,7 +91,7 @@ export default function DeviceProtocolsTab({ protocols, totalDownload, totalUplo
|
||||
const appBytes = proto.download + proto.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 key={i} className="bg-white/[0.03] border border-border/50 p-4 rounded-2xl 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">
|
||||
{formatAppLabel(proto.app_label)}
|
||||
|
||||
@@ -98,4 +98,4 @@ export function DropdownFilterSelect({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTheme } from "next-themes";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { fmtBytes } from "@/lib/utils";
|
||||
|
||||
interface GlobeDataInput {
|
||||
startLat: number;
|
||||
startLng: number;
|
||||
endLat: number;
|
||||
endLng: number;
|
||||
fromLabel: string;
|
||||
toLabel: string;
|
||||
value: number;
|
||||
download: number;
|
||||
upload: number;
|
||||
countryCode?: string;
|
||||
}
|
||||
|
||||
interface FlatWorldMapProps {
|
||||
data: GlobeDataInput[];
|
||||
}
|
||||
|
||||
export const FlatWorldMap = ({ data }: FlatWorldMapProps) => {
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<any>(null);
|
||||
const layerGroupRef = useRef<any>(null);
|
||||
const tileLayerRef = useRef<any>(null);
|
||||
const labelLayerRef = useRef<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme !== "light";
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function runMapSetup() {
|
||||
if (!mapContainerRef.current || !isMounted) return;
|
||||
|
||||
const L = (await import("leaflet")).default;
|
||||
if (!isMounted) return;
|
||||
|
||||
// Inject Leaflet CSS if not already present
|
||||
if (!document.getElementById("leaflet-css-flat")) {
|
||||
const lnk = document.createElement("link");
|
||||
lnk.id = "leaflet-css-flat";
|
||||
lnk.rel = "stylesheet";
|
||||
lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(lnk);
|
||||
lnk.onload = () => {
|
||||
if (isMounted) runMapSetup();
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
// Use dark_nolabels for base to allow overlaying clear light-colored labels
|
||||
const tileUrl = isDark
|
||||
? "https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png"
|
||||
: "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
|
||||
|
||||
if (!mapInstanceRef.current) {
|
||||
// Initialize map centered at Southeast Asia / Indonesia [15, 120]
|
||||
const map = L.map(mapContainerRef.current, {
|
||||
zoomControl: false,
|
||||
attributionControl: false,
|
||||
minZoom: 2,
|
||||
maxZoom: 10,
|
||||
worldCopyJump: true,
|
||||
}).setView([-2.5, 118.0], 3);
|
||||
|
||||
const tiles = L.tileLayer(tileUrl, { maxZoom: 19 }).addTo(map);
|
||||
tileLayerRef.current = tiles;
|
||||
|
||||
// If dark mode, overlay the high-contrast light labels layer
|
||||
if (isDark) {
|
||||
const labels = L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png", {
|
||||
maxZoom: 19,
|
||||
pane: 'shadowPane' // Places text on top of polylines
|
||||
}).addTo(map);
|
||||
labelLayerRef.current = labels;
|
||||
}
|
||||
|
||||
L.control.zoom({ position: "bottomright" }).addTo(map);
|
||||
mapInstanceRef.current = map;
|
||||
layerGroupRef.current = L.layerGroup().addTo(map);
|
||||
} else {
|
||||
// Update tile layers url if theme changes
|
||||
tileLayerRef.current.setUrl(tileUrl);
|
||||
if (isDark) {
|
||||
if (!labelLayerRef.current) {
|
||||
labelLayerRef.current = L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png", {
|
||||
maxZoom: 19,
|
||||
pane: 'shadowPane'
|
||||
}).addTo(mapInstanceRef.current);
|
||||
} else {
|
||||
labelLayerRef.current.setUrl("https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png");
|
||||
}
|
||||
} else {
|
||||
if (labelLayerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(labelLayerRef.current);
|
||||
labelLayerRef.current = null;
|
||||
}
|
||||
}
|
||||
layerGroupRef.current.clearLayers();
|
||||
}
|
||||
|
||||
const map = mapInstanceRef.current;
|
||||
const layerGroup = layerGroupRef.current;
|
||||
|
||||
// Draw active origin marker (Indonesia)
|
||||
const originIcon = L.divIcon({
|
||||
className: 'custom-map-icon',
|
||||
html: `
|
||||
<div class="relative flex items-center justify-center">
|
||||
<span class="animate-ping absolute inline-flex h-6 w-6 rounded-full bg-blue-400 opacity-60"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-blue-500 shadow-lg shadow-blue-500/50"></span>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
|
||||
const firstFlow = data[0];
|
||||
const originLat = firstFlow ? firstFlow.startLat : -6.2253265;
|
||||
const originLng = firstFlow ? firstFlow.startLng : 106.8061484;
|
||||
const originLabel = firstFlow ? firstFlow.fromLabel : "IFG LT.18 Agent HQ";
|
||||
|
||||
const originMarker = L.marker([originLat, originLng], { icon: originIcon }).addTo(layerGroup);
|
||||
|
||||
// Bind permanent light label for origin (positioned ABOVE the marker)
|
||||
originMarker.bindTooltip(`<div class="px-2 py-0.5 bg-blue-600/90 border border-blue-400/50 text-white rounded text-[10px] font-bold shadow-lg shadow-blue-900/50 uppercase tracking-wider">${originLabel}</div>`, {
|
||||
permanent: true,
|
||||
direction: 'top',
|
||||
offset: [0, -10],
|
||||
className: 'custom-node-label-tooltip'
|
||||
});
|
||||
|
||||
// Draw lines and points for destination traffic flows
|
||||
data.forEach((flow) => {
|
||||
const start: [number, number] = [flow.startLat, flow.startLng];
|
||||
const end: [number, number] = [flow.endLat, flow.endLng];
|
||||
const flowColor = isDark ? '#38bdf8' : '#0284c7';
|
||||
|
||||
// Custom DivIcon for remote country destination
|
||||
const destIcon = L.divIcon({
|
||||
className: 'custom-map-icon',
|
||||
html: `
|
||||
<div class="relative flex items-center justify-center">
|
||||
<span class="animate-pulse absolute inline-flex h-4 w-4 rounded-full" style="background-color: ${flowColor}; opacity: 0.4;"></span>
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5" style="background-color: ${flowColor}; box-shadow: 0 0 8px ${flowColor};"></span>
|
||||
</div>
|
||||
`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8]
|
||||
});
|
||||
|
||||
// Add remote marker
|
||||
const destMarker = L.marker(end, { icon: destIcon }).addTo(layerGroup);
|
||||
|
||||
// Add Polyline
|
||||
const line = L.polyline([start, end], {
|
||||
color: flowColor,
|
||||
weight: 1.5,
|
||||
opacity: 0.65,
|
||||
className: 'traffic-flow-polyline',
|
||||
}).addTo(layerGroup);
|
||||
|
||||
// Dynamic tooltip positioning to prevent overlap/collision
|
||||
let tooltipDirection: 'top' | 'bottom' | 'left' | 'right' = 'bottom';
|
||||
let tooltipOffset: [number, number] = [0, 8];
|
||||
|
||||
const labelLower = flow.toLabel.toLowerCase();
|
||||
if (labelLower.includes('internal network') || labelLower.includes('intranet')) {
|
||||
tooltipDirection = 'bottom';
|
||||
tooltipOffset = [0, 10];
|
||||
} else if (labelLower.includes('tangerang') || labelLower.includes('balaraja')) {
|
||||
tooltipDirection = 'left';
|
||||
tooltipOffset = [-8, 0];
|
||||
} else if (labelLower.includes('bandung')) {
|
||||
tooltipDirection = 'right';
|
||||
tooltipOffset = [8, 0];
|
||||
} else if (labelLower.includes('surabaya')) {
|
||||
tooltipDirection = 'bottom';
|
||||
tooltipOffset = [0, 8];
|
||||
}
|
||||
|
||||
// Bind permanent high-contrast country name label with smart offsets
|
||||
destMarker.bindTooltip(`<div class="px-1.5 py-0.5 bg-slate-900/95 border border-slate-700/80 text-slate-100 rounded text-[9.5px] font-semibold shadow-md uppercase tracking-wide">${flow.toLabel}</div>`, {
|
||||
permanent: true,
|
||||
direction: tooltipDirection,
|
||||
offset: tooltipOffset,
|
||||
className: 'custom-node-label-tooltip'
|
||||
});
|
||||
|
||||
// Bind hover HTML tooltip for deep flow stats
|
||||
const dlStr = fmtBytes(flow.download);
|
||||
const ulStr = fmtBytes(flow.upload);
|
||||
const hoverTooltipHtml = `
|
||||
<div class="px-3 py-2 bg-slate-950/95 border border-slate-700/50 backdrop-blur-md rounded-lg shadow-xl text-slate-100 text-xs flex flex-col gap-1">
|
||||
<span class="font-bold text-white tracking-wide">${flow.toLabel}</span>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 text-slate-400">
|
||||
<span>DL: <strong class="text-blue-400">${dlStr}</strong></span>
|
||||
<span class="text-slate-600">|</span>
|
||||
<span>UL: <strong class="text-emerald-400">${ulStr}</strong></span>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 mt-0.5">Coordinates: ${end[0].toFixed(2)}, ${end[1].toFixed(2)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Register hover trigger popups/tooltips on mouseover
|
||||
destMarker.on('mouseover', function (e) {
|
||||
const popup = L.popup({
|
||||
closeButton: false,
|
||||
className: 'custom-hover-flow-popup',
|
||||
offset: [0, -10]
|
||||
})
|
||||
.setLatLng(end)
|
||||
.setContent(hoverTooltipHtml)
|
||||
.openOn(map);
|
||||
|
||||
destMarker.on('mouseout', () => map.closePopup(popup));
|
||||
});
|
||||
|
||||
line.bindTooltip(hoverTooltipHtml, { sticky: true, opacity: 0.95 });
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Inject Leaflet CSS if not already present, and wait for it to load before booting
|
||||
if (!document.getElementById("leaflet-css-flat")) {
|
||||
const lnk = document.createElement("link");
|
||||
lnk.id = "leaflet-css-flat";
|
||||
lnk.rel = "stylesheet";
|
||||
lnk.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
document.head.appendChild(lnk);
|
||||
lnk.onload = () => {
|
||||
if (isMounted) runMapSetup();
|
||||
};
|
||||
} else {
|
||||
runMapSetup();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [data, isDark]);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
try {
|
||||
mapInstanceRef.current.remove();
|
||||
} catch (_) {}
|
||||
mapInstanceRef.current = null;
|
||||
layerGroupRef.current = null;
|
||||
tileLayerRef.current = null;
|
||||
labelLayerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-[500px] rounded-xl overflow-hidden border border-white/5 bg-[#090d16] flex items-center justify-center">
|
||||
{/* Dynamic inline stylesheet to customize leaflet default styles, hover tooltips, and animations */}
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
/* CRITICAL RESET: Override the global '*' 500ms transition from globals.css to fix marker/line drag lag */
|
||||
.leaflet-container *,
|
||||
.leaflet-container *::before,
|
||||
.leaflet-container *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.leaflet-container {
|
||||
background-color: ${isDark ? "#090d16" : "#f1f5f9"} !important;
|
||||
font-family: inherit;
|
||||
}
|
||||
.leaflet-bar {
|
||||
border: 1px solid rgba(255,255,255,0.08) !important;
|
||||
background-color: rgba(24, 24, 27, 0.9) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.5) !important;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: transparent !important;
|
||||
color: #f4f4f5 !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08) !important;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.leaflet-bar a:hover {
|
||||
background-color: rgba(255,255,255,0.05) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.leaflet-tooltip {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.leaflet-tooltip-pane {
|
||||
z-index: 650;
|
||||
}
|
||||
.custom-node-label-tooltip {
|
||||
pointer-events: none !important;
|
||||
user-select: none !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
.custom-hover-flow-popup .leaflet-popup-content-wrapper {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.custom-hover-flow-popup .leaflet-popup-content {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
.custom-hover-flow-popup .leaflet-popup-tip {
|
||||
background: #020617 !important;
|
||||
border: 1px solid rgba(255,255,255,0.1) !important;
|
||||
}
|
||||
.traffic-flow-polyline {
|
||||
stroke-dasharray: 4, 4;
|
||||
animation: polyFlow 30s linear infinite;
|
||||
}
|
||||
@keyframes polyFlow {
|
||||
to {
|
||||
stroke-dashoffset: -100;
|
||||
}
|
||||
}
|
||||
`}} />
|
||||
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-slate-950/20 backdrop-blur-sm z-[1000] flex items-center justify-center gap-2 text-sm text-slate-400">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
|
||||
<span>Loading map interface...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={mapContainerRef} className="w-full h-full" id="flat-world-map" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -57,7 +57,7 @@ export function HelpCenterFAB() {
|
||||
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"
|
||||
className="fixed bottom-24 lg: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" />
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// Client-side cache to prevent duplicate fetches for the same IP
|
||||
const geoIpCache: Record<string, { isp: string; country: string; as_org: string }> = {};
|
||||
|
||||
export function IpDetails({ ip }: { ip: string }) {
|
||||
const [details, setDetails] = useState<{ isp: string; country: string; as_org: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ip) return;
|
||||
|
||||
// Check memory cache first
|
||||
if (geoIpCache[ip]) {
|
||||
setDetails(geoIpCache[ip]);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
fetch(`/api/auth/geoip?ip=${encodeURIComponent(ip)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!isMounted) return;
|
||||
const resolved = {
|
||||
isp: data.isp || "Unknown ISP",
|
||||
country: data.country || "Remote",
|
||||
as_org: data.as_org || "Public Network",
|
||||
};
|
||||
geoIpCache[ip] = resolved;
|
||||
setDetails(resolved);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("GeoIP lookup client error:", err);
|
||||
if (isMounted) {
|
||||
setDetails({
|
||||
isp: "Public IP",
|
||||
country: "Remote",
|
||||
as_org: "Public Network",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [ip]);
|
||||
|
||||
if (!details) {
|
||||
return <span className="text-[10px] text-slate-500 animate-pulse">loading...</span>;
|
||||
}
|
||||
|
||||
const isLocal = details.country === "Local";
|
||||
|
||||
return (
|
||||
<div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate" title={`${details.isp}${!isLocal ? ` • ${details.country}` : ''}`}>
|
||||
<span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}>
|
||||
{details.isp}
|
||||
</span>
|
||||
{!isLocal && (
|
||||
<span className="text-slate-400 font-normal">
|
||||
{" • "}{details.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// Client-side cache to prevent duplicate fetches for the same IP
|
||||
const geoIpCache: Record<string, { isp: string; country: string; as_org: string }> = {};
|
||||
|
||||
export function IpDetails({ ip }: { ip: string }) {
|
||||
const [details, setDetails] = useState<{ isp: string; country: string; as_org: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ip) return;
|
||||
|
||||
// Check memory cache first
|
||||
if (geoIpCache[ip]) {
|
||||
setDetails(geoIpCache[ip]);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
fetch(`/api/auth/geoip?ip=${encodeURIComponent(ip)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (!isMounted) return;
|
||||
const resolved = {
|
||||
isp: data.isp || "Unknown ISP",
|
||||
country: data.country || "Remote",
|
||||
as_org: data.as_org || "Public Network",
|
||||
};
|
||||
geoIpCache[ip] = resolved;
|
||||
setDetails(resolved);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("GeoIP lookup client error:", err);
|
||||
if (isMounted) {
|
||||
setDetails({
|
||||
isp: "Public IP",
|
||||
country: "Remote",
|
||||
as_org: "Public Network",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [ip]);
|
||||
|
||||
if (!details) {
|
||||
return <span className="text-[10px] text-slate-500 animate-pulse">loading...</span>;
|
||||
}
|
||||
|
||||
const isLocal = details.country === "Local";
|
||||
|
||||
return (
|
||||
<div className="text-[10px] leading-tight mt-0.5 max-w-[180px] truncate" title={`${details.isp}${!isLocal ? ` • ${details.country}` : ''}`}>
|
||||
<span className={isLocal ? "text-emerald-500/90 font-medium" : "text-cyan-400/90 font-medium"}>
|
||||
{details.isp}
|
||||
</span>
|
||||
{!isLocal && (
|
||||
<span className="text-slate-400 font-normal">
|
||||
{" • "}{details.country}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+82
-82
@@ -1,82 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setVisible(true);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
if (visible) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "unset";
|
||||
}, 480);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [isOpen, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isMounted || !visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className={cn(
|
||||
"relative z-[9999] w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl sm:my-8",
|
||||
isClosing ? 'animate-modal-out' : 'animate-modal-in',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-foreground tracking-tight">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, className }: ModalProps) {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setVisible(true);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
if (visible) {
|
||||
setIsClosing(true);
|
||||
const timer = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setIsClosing(false);
|
||||
document.body.style.overflow = "unset";
|
||||
}, 480);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [isOpen, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!isMounted || !visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 bg-black/40 dark:bg-black/75 backdrop-blur-sm ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className={cn(
|
||||
"relative z-[9999] w-full max-w-md transform overflow-hidden rounded-xl border border-border bg-card shadow-2xl sm:my-8",
|
||||
isClosing ? 'animate-modal-out' : 'animate-modal-in',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
<h3 className="text-lg font-semibold text-foreground tracking-tight">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -66,24 +66,24 @@ export function RemoteIpDetailModal({ ip, onClose }: Props) {
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" onClick={handleClose}>
|
||||
<div className={`absolute inset-0 bg-black/60 backdrop-blur-md ${isClosing ? 'animate-overlay-out' : 'animate-overlay-in'}`} />
|
||||
<div
|
||||
className={`relative bg-card border border-border rounded-3xl w-full max-w-5xl h-[85vh] flex flex-col shadow-2xl shadow-black/10 dark:shadow-blue-900/20 overflow-hidden backdrop-blur-xl ${isClosing ? 'animate-modal-out' : 'animate-modal-in'}`}
|
||||
className={`relative bg-card border border-border/50 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 font-sans ${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="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between px-4 py-4 sm:px-6 sm: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-3 sm:gap-4 min-w-0">
|
||||
<div className="relative flex-shrink-0">
|
||||
<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 className="p-2 sm:p-3 bg-gradient-to-br from-indigo-500/20 to-blue-500/10 rounded-lg sm:rounded-xl ring-1 ring-indigo-500/30 relative">
|
||||
<Globe className="w-5 h-5 sm:w-6 sm: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 className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-wrap">
|
||||
<h3 className="text-base sm:text-xl font-bold text-card-foreground tracking-tight truncate">{ip}</h3>
|
||||
{isRefreshing && <RefreshCcw className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-indigo-500 dark:text-indigo-400 animate-spin flex-shrink-0" />}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<IpDetails ip={ip} />
|
||||
@@ -91,18 +91,18 @@ export function RemoteIpDetailModal({ ip, onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center justify-between sm:justify-end gap-2 flex-shrink-0 w-full sm:w-auto border-t border-border/40 pt-3 sm:border-t-0 sm:pt-0">
|
||||
<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"
|
||||
className="p-2 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"
|
||||
className="p-2 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>
|
||||
@@ -177,7 +177,7 @@ export function RemoteIpDetailModal({ ip, onClose }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50/50 dark:bg-white/[0.02] border border-border rounded-2xl p-6">
|
||||
<div className="bg-white/[0.02] border border-border/50 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>
|
||||
|
||||
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user