Implement dynamic data range interval and notification panel with redirection highlights
This commit is contained in:
1 parent
6c8874afdc
commit
1d8f8244a6
8 files changed
+358
-16
No files matched your search
@@ -1691,10 +1691,20 @@ function getIntelStats(siteUuid = null, agentUuid = null) {
|
|||||||
return counts;
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDataInterval() {
|
||||||
|
const d = getDB();
|
||||||
|
const row = d.prepare("SELECT MIN(fetched_at) as start_t, MAX(fetched_at) as end_t FROM devices").get();
|
||||||
|
return {
|
||||||
|
start: row?.start_t || null,
|
||||||
|
end: row?.end_t || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
updateUserPassword,
|
updateUserPassword,
|
||||||
getDB,
|
getDB,
|
||||||
|
getDataInterval,
|
||||||
insertBandwidthApps, insertDevices, insertFlows, insertThreats,
|
insertBandwidthApps, insertDevices, insertFlows, insertThreats,
|
||||||
insertProtocols, insertCountries, insertDNS, insertEvents,
|
insertProtocols, insertCountries, insertDNS, insertEvents,
|
||||||
insertBandwidthTimeline,
|
insertBandwidthTimeline,
|
||||||
|
|||||||
@@ -385,4 +385,14 @@ router.get('/security-devices', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/dashboard/data-interval
|
||||||
|
router.get('/data-interval', (req, res) => {
|
||||||
|
try {
|
||||||
|
const data = db.getDataInterval();
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ ok: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;␍
|
module.exports = router;␍
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const assert = require('assert');
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const dbModule = require('../database');
|
||||||
|
|
||||||
|
console.log("=== STARTING TDD TEST FOR NOTIFICATIONS & INTERVALS ===");
|
||||||
|
|
||||||
|
// 1. Test Data Interval Function
|
||||||
|
try {
|
||||||
|
const interval = dbModule.getDataInterval();
|
||||||
|
console.log("Data Interval retrieved:", interval);
|
||||||
|
assert.ok(interval.start, "Start timestamp must exist");
|
||||||
|
assert.ok(interval.end, "End timestamp must exist");
|
||||||
|
assert.ok(new Date(interval.start).getTime() <= new Date(interval.end).getTime(), "Start timestamp must be <= End timestamp");
|
||||||
|
console.log("✓ Test 1 Passed: getDataInterval returns correct timestamp range");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Test 1 Failed:", e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Test Events & Threats Availability
|
||||||
|
try {
|
||||||
|
const events = dbModule.getLatestEvents(10);
|
||||||
|
const threats = dbModule.getLatestThreats(10);
|
||||||
|
console.log(`Latest events count: ${events.length}, latest threats count: ${threats.length}`);
|
||||||
|
assert.ok(Array.isArray(events), "Events must be an array");
|
||||||
|
assert.ok(Array.isArray(threats), "Threats must be an array");
|
||||||
|
console.log("✓ Test 2 Passed: getLatestEvents and getLatestThreats return arrays");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Test 2 Failed:", e.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("=== ALL TESTS PASSED SUCCESSFULLY! ===");
|
||||||
@@ -6,9 +6,14 @@ import { DataTable, Column } from "@/components/ui/DataTable";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||||
import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
import { Loader2, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
export default function EventsPage() {
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
function EventsContent() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const { data, isLoading } = useEvents(100); // fetch 100 events
|
const { data, isLoading } = useEvents(100); // fetch 100 events
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const highlightId = searchParams.get("highlight");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -85,9 +90,22 @@ export default function EventsPage() {
|
|||||||
(row.message || "").toLowerCase().includes(q)
|
(row.message || "").toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
getRowClassName={(row) => {
|
||||||
|
return highlightId && row.id.toString() === highlightId
|
||||||
|
? "bg-yellow-500/10 border-l-2 border-yellow-500 animate-pulse font-semibold"
|
||||||
|
: "";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function EventsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex items-center justify-center p-12"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>}>
|
||||||
|
<EventsContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,9 +8,14 @@ import { ShieldCheck, Loader2 } from "lucide-react";
|
|||||||
import { useThreats, ThreatStat } from "@/lib/api";
|
import { useThreats, ThreatStat } from "@/lib/api";
|
||||||
import { IpDetails } from "@/components/ui/IpDetails";
|
import { IpDetails } from "@/components/ui/IpDetails";
|
||||||
|
|
||||||
export default function ThreatsPage() {
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
function ThreatsContent() {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const threats = useThreats(100);
|
const threats = useThreats(100);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const highlightId = searchParams.get("highlight");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -24,7 +29,7 @@ export default function ThreatsPage() {
|
|||||||
if (s.includes('high') || s.includes('critical')) return <Badge variant="red">{sev}</Badge>;
|
if (s.includes('high') || s.includes('critical')) return <Badge variant="red">{sev}</Badge>;
|
||||||
if (s.includes('med')) return <Badge variant="orange">{sev}</Badge>;
|
if (s.includes('med')) return <Badge variant="orange">{sev}</Badge>;
|
||||||
if (s.includes('low')) return <Badge variant="yellow">{sev}</Badge>;
|
if (s.includes('low')) return <Badge variant="yellow">{sev}</Badge>;
|
||||||
return <Badge variant="green">{sev}</Badge>;
|
return <Badge variant="blue">{sev}</Badge>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: Column<ThreatStat>[] = [
|
const columns: Column<ThreatStat>[] = [
|
||||||
@@ -79,9 +84,22 @@ export default function ThreatsPage() {
|
|||||||
(row.threat_type || "").toLowerCase().includes(q) ||
|
(row.threat_type || "").toLowerCase().includes(q) ||
|
||||||
(row.app_label || "").toLowerCase().includes(q);
|
(row.app_label || "").toLowerCase().includes(q);
|
||||||
}}
|
}}
|
||||||
|
getRowClassName={(row) => {
|
||||||
|
return highlightId && row.id.toString() === highlightId
|
||||||
|
? "bg-red-500/15 border-l-2 border-red-500 animate-pulse font-semibold"
|
||||||
|
: "";
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ThreatsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="flex items-center justify-center p-12"><Loader2 className="w-8 h-8 animate-spin text-primary" /></div>}>
|
||||||
|
<ThreatsContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,16 +7,48 @@ import { Header } from "./Header";
|
|||||||
export function DashboardLayout({ children }: { children: ReactNode }) {
|
export function DashboardLayout({ children }: { children: ReactNode }) {
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [lastUpdate, setLastUpdate] = useState<string>("—");
|
const [lastUpdate, setLastUpdate] = useState<string>("—");
|
||||||
|
const [intervalText, setIntervalText] = useState<string>("");
|
||||||
|
|
||||||
|
const fetchDataIntervalRange = () => {
|
||||||
|
fetch('/api/dashboard/data-interval')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((res) => {
|
||||||
|
if (res.ok && res.data) {
|
||||||
|
const { start, end } = res.data;
|
||||||
|
if (start && end) {
|
||||||
|
const sDate = new Date(start);
|
||||||
|
const eDate = new Date(end);
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
};
|
||||||
|
// e.g. "1 Jul 2026 04:57 - 1 Jul 2026 08:42"
|
||||||
|
setIntervalText(`${sDate.toLocaleString('id-ID', options)} - ${eDate.toLocaleString('id-ID', options)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLastUpdate(new Date().toLocaleString('id-ID'));
|
const updateTime = new Date().toLocaleString('id-ID');
|
||||||
|
setLastUpdate(updateTime);
|
||||||
|
fetchDataIntervalRange();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
// Simulate API refresh
|
// Poll updates on backend
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
try {
|
||||||
setLastUpdate(new Date().toLocaleString('id-ID'));
|
await fetch('/api/dashboard/refresh', { method: 'POST' });
|
||||||
|
} catch {}
|
||||||
|
const updateTime = new Date().toLocaleString('id-ID');
|
||||||
|
setLastUpdate(updateTime);
|
||||||
|
fetchDataIntervalRange();
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,6 +60,7 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
|
|||||||
lastUpdate={lastUpdate}
|
lastUpdate={lastUpdate}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
|
intervalText={intervalText}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-y-auto bg-[#0d1117] p-6">
|
<main className="flex-1 overflow-y-auto bg-[#0d1117] p-6">
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { Bell, RefreshCw } from "lucide-react";
|
import { Bell, RefreshCw, Calendar, AlertTriangle, Info, Clock, Check } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useThreats, useEvents } from "@/lib/api";
|
||||||
|
|
||||||
const AGENT_LABELS: Record<string, string> = {
|
const AGENT_LABELS: Record<string, string> = {
|
||||||
'2F-TF-1D-GK': 'JRP Cibubur',
|
'2F-TF-1D-GK': 'JRP Cibubur',
|
||||||
@@ -9,16 +11,36 @@ const AGENT_LABELS: Record<string, string> = {
|
|||||||
'F6-2V-DT-8A': 'CPI Balaraja',
|
'F6-2V-DT-8A': 'CPI Balaraja',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface AppNotification {
|
||||||
|
id: string;
|
||||||
|
dbId: number;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
severity: string;
|
||||||
|
timestamp: string;
|
||||||
|
type: 'threat' | 'event';
|
||||||
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
lastUpdate = "—",
|
lastUpdate = "—",
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
onRefresh = () => {},
|
onRefresh = () => {},
|
||||||
|
intervalText = "",
|
||||||
}: {
|
}: {
|
||||||
lastUpdate?: string;
|
lastUpdate?: string;
|
||||||
isRefreshing?: boolean;
|
isRefreshing?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
|
intervalText?: string;
|
||||||
}) {
|
}) {
|
||||||
const [user, setUser] = useState<{ role?: string; agent_uuid?: string } | null>(null);
|
const [user, setUser] = useState<{ role?: string; agent_uuid?: string } | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [readIds, setReadIds] = useState<string[]>([]);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Load threats and events
|
||||||
|
const { data: threats } = useThreats(30);
|
||||||
|
const { data: events } = useEvents(30);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/me')
|
fetch('/api/auth/me')
|
||||||
@@ -29,16 +51,135 @@ export function Header({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error(err));
|
.catch(err => console.error(err));
|
||||||
|
|
||||||
|
// Load read notifications
|
||||||
|
const stored = localStorage.getItem('read_notifications');
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
setReadIds(JSON.parse(stored));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle click outside to close dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Combine threats and events into notifications
|
||||||
|
const notifications = useMemo<AppNotification[]>(() => {
|
||||||
|
const list: AppNotification[] = [];
|
||||||
|
|
||||||
|
if (threats) {
|
||||||
|
for (const t of threats) {
|
||||||
|
list.push({
|
||||||
|
id: `threat-${t.id || t.detected_at}`,
|
||||||
|
dbId: t.id,
|
||||||
|
title: "Ancaman Terdeteksi!",
|
||||||
|
message: `${t.threat_type || "Malicious traffic"} dari IP ${t.ip_address || "unknown"}`,
|
||||||
|
severity: t.severity || "high",
|
||||||
|
timestamp: t.detected_at || "",
|
||||||
|
type: "threat"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events) {
|
||||||
|
for (const e of events) {
|
||||||
|
const severity = (e.severity || "info").toLowerCase();
|
||||||
|
if (severity === "critical" || severity === "high" || severity === "medium" || severity === "warning") {
|
||||||
|
list.push({
|
||||||
|
id: `event-${e.id || e.timestamp}`,
|
||||||
|
dbId: e.id,
|
||||||
|
title: `Event: ${e.event_type || "Sistem"}`,
|
||||||
|
message: e.message || "",
|
||||||
|
severity: e.severity || "warning",
|
||||||
|
timestamp: e.timestamp || "",
|
||||||
|
type: "event"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by timestamp desc
|
||||||
|
list.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
||||||
|
return list;
|
||||||
|
}, [threats, events]);
|
||||||
|
|
||||||
|
const unreadCount = useMemo(() => {
|
||||||
|
return notifications.filter(n => !readIds.includes(n.id)).length;
|
||||||
|
}, [notifications, readIds]);
|
||||||
|
|
||||||
|
const markAllAsRead = () => {
|
||||||
|
const allIds = notifications.map(n => n.id);
|
||||||
|
setReadIds(allIds);
|
||||||
|
localStorage.setItem('read_notifications', JSON.stringify(allIds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNotificationClick = (n: AppNotification) => {
|
||||||
|
if (!readIds.includes(n.id)) {
|
||||||
|
const updated = [...readIds, n.id];
|
||||||
|
setReadIds(updated);
|
||||||
|
localStorage.setItem('read_notifications', JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
|
||||||
|
// Direct and highlight query
|
||||||
|
const target = n.type === 'threat'
|
||||||
|
? `/threats?highlight=${n.dbId}`
|
||||||
|
: `/events?highlight=${n.dbId}`;
|
||||||
|
|
||||||
|
router.push(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityStyles = (severity: string) => {
|
||||||
|
const s = severity.toLowerCase();
|
||||||
|
if (s.includes('critical') || s.includes('high')) {
|
||||||
|
return {
|
||||||
|
bg: 'bg-red-500/10 hover:bg-red-500/15',
|
||||||
|
border: 'border-red-500/20',
|
||||||
|
text: 'text-red-400',
|
||||||
|
icon: <AlertTriangle className="w-4 h-4 text-red-500 shrink-0" />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (s.includes('med') || s.includes('warning')) {
|
||||||
|
return {
|
||||||
|
bg: 'bg-yellow-500/10 hover:bg-yellow-500/15',
|
||||||
|
border: 'border-yellow-500/20',
|
||||||
|
text: 'text-yellow-400',
|
||||||
|
icon: <AlertTriangle className="w-4 h-4 text-yellow-500 shrink-0" />
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bg: 'bg-blue-500/10 hover:bg-blue-500/15',
|
||||||
|
border: 'border-blue-500/20',
|
||||||
|
text: 'text-blue-400',
|
||||||
|
icon: <Info className="w-4 h-4 text-blue-500 shrink-0" />
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center justify-between border-b border-border bg-background px-6">
|
<header className="flex h-16 shrink-0 items-center justify-between border-b border-border bg-background px-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1 text-xs text-muted-foreground">
|
||||||
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
Site: {process.env.NEXT_PUBLIC_SITE_ID || "Unknown Site"}
|
Site: {process.env.NEXT_PUBLIC_SITE_ID || "Unknown Site"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{intervalText && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full bg-blue-500/10 border border-blue-500/20 px-3 py-1 text-xs text-blue-400 font-medium">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
<span>Rentang Data: {intervalText}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{user?.role === 'AGENT_VIEWER' && (
|
{user?.role === 'AGENT_VIEWER' && (
|
||||||
<div className="flex items-center gap-2 rounded-full border border-blue-500/30 bg-blue-950/40 px-3 py-1 text-xs text-blue-300 shadow-[0_0_10px_rgba(59,130,246,0.1)]">
|
<div className="flex items-center gap-2 rounded-full border border-blue-500/30 bg-blue-950/40 px-3 py-1 text-xs text-blue-300 shadow-[0_0_10px_rgba(59,130,246,0.1)]">
|
||||||
@@ -61,10 +202,83 @@ export function Header({
|
|||||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
<button className="relative rounded-full p-2 text-muted-foreground hover:bg-secondary hover:text-white transition-colors">
|
|
||||||
<Bell className="h-5 w-5" />
|
{/* 🔔 Notifications Button & Panel */}
|
||||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-destructive" />
|
<div className="relative" ref={dropdownRef}>
|
||||||
</button>
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="relative rounded-full p-2 text-muted-foreground hover:bg-secondary hover:text-white transition-colors focus:outline-none"
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[9px] font-bold text-white shadow-lg">
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2.5 w-96 origin-top-right rounded-2xl border border-border/80 bg-card/95 backdrop-blur-xl p-4 shadow-2xl ring-1 ring-black/5 focus:outline-none z-50 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between border-b border-border/50 pb-2.5 mb-2.5">
|
||||||
|
<span className="text-sm font-semibold text-white flex items-center gap-1.5">
|
||||||
|
<Bell className="w-4 h-4 text-primary" /> Notifikasi
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="text-[11px] text-muted-foreground font-normal">
|
||||||
|
({unreadCount} baru)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={markAllAsRead}
|
||||||
|
className="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1 font-medium"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" /> Tandai semua dibaca
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-80 overflow-y-auto space-y-2 pr-1 custom-scrollbar">
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||||
|
Tidak ada notifikasi keamanan saat ini.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((n) => {
|
||||||
|
const isRead = readIds.includes(n.id);
|
||||||
|
const styles = getSeverityStyles(n.severity);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
onClick={() => handleNotificationClick(n)}
|
||||||
|
className={`flex gap-3 p-3 rounded-xl border transition-all cursor-pointer select-none group ${styles.bg} ${styles.border} ${!isRead ? 'ring-1 ring-primary/25 shadow-sm' : 'opacity-60 hover:opacity-100'}`}
|
||||||
|
>
|
||||||
|
{styles.icon}
|
||||||
|
<div className="flex-1 space-y-0.5 min-w-0">
|
||||||
|
<div className="flex justify-between items-start gap-1">
|
||||||
|
<span className={`text-xs font-semibold truncate ${styles.text}`}>
|
||||||
|
{n.title}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-500 whitespace-nowrap flex items-center gap-0.5">
|
||||||
|
<Clock className="w-2.5 h-2.5" />
|
||||||
|
{new Date(n.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-300 leading-normal line-clamp-2">
|
||||||
|
{n.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!isRead && (
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-primary shrink-0 self-center" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface DataTableProps<T> {
|
|||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
searchFilter?: (row: T, query: string) => boolean;
|
searchFilter?: (row: T, query: string) => boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
getRowClassName?: (row: T) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTable<T>({
|
export function DataTable<T>({
|
||||||
@@ -22,7 +23,8 @@ export function DataTable<T>({
|
|||||||
columns,
|
columns,
|
||||||
searchPlaceholder = "Search...",
|
searchPlaceholder = "Search...",
|
||||||
searchFilter,
|
searchFilter,
|
||||||
isLoading
|
isLoading,
|
||||||
|
getRowClassName
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
@@ -84,7 +86,10 @@ export function DataTable<T>({
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredData.map((row, i) => (
|
filteredData.map((row, i) => (
|
||||||
<tr key={i} className="hover:bg-primary/5 transition-colors duration-200 group">
|
<tr
|
||||||
|
key={i}
|
||||||
|
className={`hover:bg-primary/5 transition-colors duration-200 group ${getRowClassName ? getRowClassName(row) : ""}`}
|
||||||
|
>
|
||||||
{columns.map((col, j) => (
|
{columns.map((col, j) => (
|
||||||
<td key={j} className={`px-4 py-3 whitespace-nowrap text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
<td key={j} className={`px-4 py-3 whitespace-nowrap text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||||
{col.accessor(row, i)}
|
{col.accessor(row, i)}
|
||||||
|
|||||||
Reference in new issue
Block a user