Implement dynamic data range interval and notification panel with redirection highlights
This commit is contained in:
1 parent
6c8874afdc
commit
1d8f8244a6
8 files changed
+359
-17
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;␍
|
||||
@@ -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 { 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"
|
||||
: "";
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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 { 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 <Badge variant="red">{sev}</Badge>;
|
||||
if (s.includes('med')) return <Badge variant="orange">{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>[] = [
|
||||
@@ -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"
|
||||
: "";
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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 }) {
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
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(() => {
|
||||
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 }) {
|
||||
<Header
|
||||
lastUpdate={lastUpdate}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
onRefresh={handleRefresh}
|
||||
intervalText={intervalText}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto bg-[#0d1117] p-6">
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'2F-TF-1D-GK': 'JRP Cibubur',
|
||||
@@ -9,16 +11,36 @@ const AGENT_LABELS: Record<string, string> = {
|
||||
'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<string[]>([]);
|
||||
const dropdownRef = useRef<HTMLDivElement>(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<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 (
|
||||
<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="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Site: {process.env.NEXT_PUBLIC_SITE_ID || "Unknown Site"}
|
||||
</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 className="flex items-center gap-4">
|
||||
{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)]">
|
||||
@@ -61,10 +202,83 @@ export function Header({
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</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" />
|
||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-destructive" />
|
||||
</button>
|
||||
|
||||
{/* 🔔 Notifications Button & Panel */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<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>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ interface DataTableProps<T> {
|
||||
searchPlaceholder?: string;
|
||||
searchFilter?: (row: T, query: string) => boolean;
|
||||
isLoading?: boolean;
|
||||
getRowClassName?: (row: T) => string;
|
||||
}
|
||||
|
||||
export function DataTable<T>({
|
||||
@@ -22,7 +23,8 @@ export function DataTable<T>({
|
||||
columns,
|
||||
searchPlaceholder = "Search...",
|
||||
searchFilter,
|
||||
isLoading
|
||||
isLoading,
|
||||
getRowClassName
|
||||
}: DataTableProps<T>) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
@@ -84,7 +86,10 @@ export function DataTable<T>({
|
||||
</tr>
|
||||
) : (
|
||||
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) => (
|
||||
<td key={j} className={`px-4 py-3 whitespace-nowrap text-foreground group-hover:text-white transition-colors ${col.className || ""}`}>
|
||||
{col.accessor(row, i)}
|
||||
|
||||
Reference in new issue
Block a user