Implement dynamic data range interval and notification panel with redirection highlights

This commit is contained in:
vanne committed 2026-07-01 15:49:36 +07:00
1 parent 6c8874afdc
commit 1d8f8244a6
8 files changed
+359 -17

No files matched your search

+10
View File
@@ -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,
+10
View File
@@ -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! ===");
+19 -1
View File
@@ -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>
);
}
+20 -2
View File
@@ -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>
);
}
+38 -5
View File
@@ -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);
}; };
@@ -27,7 +59,8 @@ export function DashboardLayout({ children }: { children: ReactNode }) {
<Header <Header
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">
+221 -7
View File
@@ -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>
); );
+7 -2
View File
@@ -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)}