diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx
index 525bd11..0364435 100644
--- a/src/components/layout/Header.tsx
+++ b/src/components/layout/Header.tsx
@@ -1,7 +1,9 @@
"use client";
-import { useState, useEffect } from "react";
-import { Bell, RefreshCw } from "lucide-react";
+import { useState, useEffect, useMemo, useRef } from "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
= {
'2F-TF-1D-GK': 'JRP Cibubur',
@@ -9,16 +11,36 @@ const AGENT_LABELS: Record = {
'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({
lastUpdate = "—",
isRefreshing = false,
onRefresh = () => {},
+ intervalText = "",
}: {
lastUpdate?: string;
isRefreshing?: boolean;
onRefresh?: () => void;
+ intervalText?: string;
}) {
const [user, setUser] = useState<{ role?: string; agent_uuid?: string } | null>(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const [readIds, setReadIds] = useState([]);
+ const dropdownRef = useRef(null);
+ const router = useRouter();
+
+ // Load threats and events
+ const { data: threats } = useThreats(30);
+ const { data: events } = useEvents(30);
useEffect(() => {
fetch('/api/auth/me')
@@ -29,16 +51,135 @@ export function Header({
}
})
.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(() => {
+ 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:
+ };
+ }
+ 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:
+ };
+ }
+ return {
+ bg: 'bg-blue-500/10 hover:bg-blue-500/15',
+ border: 'border-blue-500/20',
+ text: 'text-blue-400',
+ icon:
+ };
+ };
+
return (
-
+
Site: {process.env.NEXT_PUBLIC_SITE_ID || "Unknown Site"}
+
+ {intervalText && (
+
+
+ Rentang Data: {intervalText}
+
+ )}
+
{user?.role === 'AGENT_VIEWER' && (
@@ -61,10 +202,83 @@ export function Header({
Refresh
-
+
+ {/* 🔔 Notifications Button & Panel */}
+
+
+
+ {isOpen && (
+
+
+
+ Notifikasi
+ {unreadCount > 0 && (
+
+ ({unreadCount} baru)
+
+ )}
+
+ {unreadCount > 0 && (
+
+ )}
+
+
+
+ {notifications.length === 0 ? (
+
+ Tidak ada notifikasi keamanan saat ini.
+
+ ) : (
+ notifications.map((n) => {
+ const isRead = readIds.includes(n.id);
+ const styles = getSeverityStyles(n.severity);
+ return (
+
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}
+
+
+
+ {n.title}
+
+
+
+ {new Date(n.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
+
+
+ {n.message}
+
+
+ {!isRead && (
+
+ )}
+
+ );
+ })
+ )}
+
+
+ )}
+
);
diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx
index 83aaf35..1f8a48b 100644
--- a/src/components/ui/DataTable.tsx
+++ b/src/components/ui/DataTable.tsx
@@ -15,6 +15,7 @@ interface DataTableProps
{
searchPlaceholder?: string;
searchFilter?: (row: T, query: string) => boolean;
isLoading?: boolean;
+ getRowClassName?: (row: T) => string;
}
export function DataTable({
@@ -22,7 +23,8 @@ export function DataTable({
columns,
searchPlaceholder = "Search...",
searchFilter,
- isLoading
+ isLoading,
+ getRowClassName
}: DataTableProps) {
const [query, setQuery] = useState("");
@@ -84,7 +86,10 @@ export function DataTable({
) : (
filteredData.map((row, i) => (
-
+
{columns.map((col, j) => (
|
{col.accessor(row, i)}
|