diff --git a/backend/database.js b/backend/database.js index b209ee4..9879c47 100644 --- a/backend/database.js +++ b/backend/database.js @@ -1691,10 +1691,20 @@ function getIntelStats(siteUuid = null, agentUuid = null) { 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 = { getUserByUsername, updateUserPassword, getDB, + getDataInterval, insertBandwidthApps, insertDevices, insertFlows, insertThreats, insertProtocols, insertCountries, insertDNS, insertEvents, insertBandwidthTimeline, diff --git a/backend/routes/dashboard.js b/backend/routes/dashboard.js index 0bb752a..de61a0a 100644 --- a/backend/routes/dashboard.js +++ b/backend/routes/dashboard.js @@ -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; \ No newline at end of file diff --git a/backend/tests/test_notifications_and_interval.js b/backend/tests/test_notifications_and_interval.js new file mode 100644 index 0000000..cbc66a8 --- /dev/null +++ b/backend/tests/test_notifications_and_interval.js @@ -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! ==="); diff --git a/src/app/(dashboard)/events/page.tsx b/src/app/(dashboard)/events/page.tsx index e644fd8..fa0f874 100644 --- a/src/app/(dashboard)/events/page.tsx +++ b/src/app/(dashboard)/events/page.tsx @@ -6,9 +6,14 @@ import { DataTable, Column } from "@/components/ui/DataTable"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; 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 { data, isLoading } = useEvents(100); // fetch 100 events + const searchParams = useSearchParams(); + const highlightId = searchParams.get("highlight"); useEffect(() => { setMounted(true); @@ -85,9 +90,22 @@ export default function EventsPage() { (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" + : ""; + }} /> ); } + +export default function EventsPage() { + return ( + }> + + + ); +} diff --git a/src/app/(dashboard)/threats/page.tsx b/src/app/(dashboard)/threats/page.tsx index 8ef8ae4..522a61a 100644 --- a/src/app/(dashboard)/threats/page.tsx +++ b/src/app/(dashboard)/threats/page.tsx @@ -8,9 +8,14 @@ import { ShieldCheck, Loader2 } from "lucide-react"; import { useThreats, ThreatStat } from "@/lib/api"; 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 threats = useThreats(100); + const searchParams = useSearchParams(); + const highlightId = searchParams.get("highlight"); useEffect(() => { setMounted(true); @@ -24,7 +29,7 @@ export default function ThreatsPage() { if (s.includes('high') || s.includes('critical')) return {sev}; if (s.includes('med')) return {sev}; if (s.includes('low')) return {sev}; - return {sev}; + return {sev}; }; const columns: Column[] = [ @@ -79,9 +84,22 @@ export default function ThreatsPage() { (row.threat_type || "").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" + : ""; + }} /> ); } + +export default function ThreatsPage() { + return ( + }> + + + ); +} diff --git a/src/components/layout/DashboardLayout.tsx b/src/components/layout/DashboardLayout.tsx index a835ddf..209f753 100644 --- a/src/components/layout/DashboardLayout.tsx +++ b/src/components/layout/DashboardLayout.tsx @@ -7,16 +7,48 @@ import { Header } from "./Header"; export function DashboardLayout({ children }: { children: ReactNode }) { const [isRefreshing, setIsRefreshing] = useState(false); const [lastUpdate, setLastUpdate] = useState("—"); + const [intervalText, setIntervalText] = useState(""); + + 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(() => { - setLastUpdate(new Date().toLocaleString('id-ID')); + const updateTime = new Date().toLocaleString('id-ID'); + setLastUpdate(updateTime); + fetchDataIntervalRange(); }, []); const handleRefresh = async () => { setIsRefreshing(true); - // Simulate API refresh - await new Promise((resolve) => setTimeout(resolve, 1000)); - setLastUpdate(new Date().toLocaleString('id-ID')); + // Poll updates on backend + try { + await fetch('/api/dashboard/refresh', { method: 'POST' }); + } catch {} + const updateTime = new Date().toLocaleString('id-ID'); + setLastUpdate(updateTime); + fetchDataIntervalRange(); setIsRefreshing(false); }; @@ -27,7 +59,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
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)}