first commit

This commit is contained in:
meninjar
2026-08-05 01:30:50 +00:00
commit afb0d434bf
26 changed files with 8283 additions and 0 deletions
+383
View File
@@ -0,0 +1,383 @@
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
import Swal from 'sweetalert2';
import { Navbar } from './components/Navbar';
import { LoginScreen } from './components/LoginScreen';
import { StatsOverview } from './components/StatsOverview';
import { BruteForcePanel } from './components/BruteForcePanel';
import { FilterToolbar } from './components/FilterToolbar';
import { LogTable } from './components/LogTable';
import { BackupArchives } from './components/BackupArchives';
import { LogEntry, LogStats, LockedIP, BackupFile, UserSession, FilterState } from './types';
import { ListFilter, Archive, Info, Loader2 } from 'lucide-react';
export function App() {
const [user, setUser] = useState<UserSession | null>(null);
const [authConfig, setAuthConfig] = useState<any>({});
const [authLoading, setAuthLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'logs' | 'backup'>('logs');
const [loading, setLoading] = useState(false);
const [actionMessage, setActionMessage] = useState('');
const [logs, setLogs] = useState<LogEntry[]>([]);
const [stats, setStats] = useState<LogStats>({
total: 0,
login_success: 0,
login_failed: 0,
session_expired: 0,
errors: 0,
warnings: 0,
performance_count: 0,
avg_response_ms: 0,
brute_force_locked: 0,
});
const [lockedIPs, setLockedIPs] = useState<LockedIP[]>([]);
const [backups, setBackups] = useState<BackupFile[]>([]);
const [pagination, setPagination] = useState({
totalLogs: 0,
perPage: 100,
currentPage: 1,
totalPages: 1,
offset: 0,
});
const [filters, setFilters] = useState<FilterState>({
source: 'auto',
date: new Date().toISOString().substring(0, 10),
time_start: '',
time_end: '',
channel: 'all',
level: 'all',
search: '',
limit: 100,
page: 1,
});
// Check Auth State
const checkAuth = useCallback(async () => {
setAuthLoading(true);
try {
const res = await axios.get('/api/auth/me');
if (res.data.authenticated) {
setUser(res.data.user);
} else {
setUser(null);
}
setAuthConfig(res.data.config || {});
} catch (e) {
console.error('Failed to fetch auth state', e);
setUser(null);
} finally {
setAuthLoading(false);
}
}, []);
// Fetch Logs Data with SweetAlert2 Loading Indicator
const fetchLogs = useCallback(async () => {
if (!user) return;
setLoading(true);
Swal.fire({
title: 'Memuat Data Log Forensic...',
html: 'Sedang memindai dan mengurai data log dari server SIMRS...',
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
Swal.showLoading();
},
background: '#0f172a',
color: '#e2e8f0',
customClass: {
popup: 'border border-slate-800 rounded-2xl shadow-2xl backdrop-blur-xl',
title: 'text-cyan-400 font-bold text-sm',
htmlContainer: 'text-slate-400 text-xs mt-1',
},
});
try {
const params = new URLSearchParams({
source: filters.source,
date: filters.date,
channel: filters.channel,
level: filters.level,
time_start: filters.time_start,
time_end: filters.time_end,
search: filters.search,
page: String(filters.page),
limit: String(filters.limit),
});
const res = await axios.get(`/api/logs?${params.toString()}`);
const data = res.data;
setLogs(data.logs || []);
setStats(data.stats || {});
setLockedIPs(data.lockedIPs || []);
setBackups(data.backups || []);
if (data.pagination) {
setPagination({
totalLogs: data.pagination.totalLogs,
perPage: data.pagination.perPage,
currentPage: data.pagination.currentPage,
totalPages: data.pagination.totalPages,
offset: data.pagination.offset,
});
}
Swal.close();
} catch (err: any) {
console.error('Failed to fetch logs', err);
Swal.fire({
icon: 'error',
title: 'Gagal Memuat Log',
text: err.response?.data?.error || err.message || 'Terjadi kesalahan saat memproses data log.',
background: '#0f172a',
color: '#e2e8f0',
confirmButtonColor: '#0e7490',
customClass: {
popup: 'border border-slate-800 rounded-2xl shadow-2xl',
title: 'text-rose-400 font-bold text-sm',
},
});
} finally {
setLoading(false);
}
}, [filters, user]);
useEffect(() => {
checkAuth();
}, [checkAuth]);
useEffect(() => {
if (user) {
fetchLogs();
}
}, [fetchLogs, user]);
// Handlers
const handleLogin = async (credentials: any) => {
const res = await axios.post('/api/auth/login', credentials);
if (res.data.success) {
setUser(res.data.user);
} else {
throw new Error(res.data.error || 'Login gagal.');
}
};
const handleLogout = async () => {
await axios.post('/api/auth/logout');
setUser(null);
};
const handleUnblock = async (ip: string) => {
try {
const res = await axios.post('/api/brute-force/unblock', { target_ip: ip });
Swal.fire({
icon: 'success',
title: 'Unblock Berhasil',
text: res.data.message || `IP ${ip} berhasil dibuka blokirnya.`,
timer: 2000,
showConfirmButton: false,
background: '#0f172a',
color: '#e2e8f0',
});
fetchLogs();
} catch (err: any) {
Swal.fire({
icon: 'error',
title: 'Gagal Unblock',
text: err.response?.data?.error || 'Gagal unblock IP.',
background: '#0f172a',
color: '#e2e8f0',
});
}
};
const handleRunRetention = async () => {
try {
Swal.fire({
title: 'Jalankan Job Retention & Backup',
text: 'Apakah Anda yakin ingin memicu job pembersihan & pencadangan log manual?',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Ya, Jalankan',
cancelButtonText: 'Batal',
confirmButtonColor: '#0e7490',
cancelButtonColor: '#334155',
background: '#0f172a',
color: '#e2e8f0',
}).then(async (result) => {
if (result.isConfirmed) {
const res = await axios.post('/api/retention/run');
Swal.fire({
icon: 'success',
title: 'Job Retention Berhasil',
text: res.data.message || 'Retention job berhasil dijalankan.',
timer: 2500,
showConfirmButton: false,
background: '#0f172a',
color: '#e2e8f0',
});
fetchLogs();
}
});
} catch (err: any) {
Swal.fire({
icon: 'error',
title: 'Gagal Retention',
text: 'Gagal menjalankan retention job.',
background: '#0f172a',
color: '#e2e8f0',
});
}
};
const handleExport = (type: 'csv' | 'json') => {
const params = new URLSearchParams({
type,
source: filters.source,
date: filters.date,
channel: filters.channel,
level: filters.level,
search: filters.search,
});
window.open(`/api/export?${params.toString()}`, '_blank');
};
// Keyboard Shortcuts Listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
(e.ctrlKey && e.altKey && (e.key === 'f' || e.key === 'F')) ||
(e.ctrlKey && e.shiftKey && (e.key === 'F' || e.key === 'f'))
) {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
// 1. Initial Loading State
if (authLoading) {
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-8 h-8 text-teal-400 animate-spin" />
<p className="text-xs text-slate-400 font-medium">Memeriksa Sesi Autentikasi...</p>
</div>
</div>
);
}
// 2. MANDATORY LOGIN CHECK: If not logged in, show full-screen LoginScreen exclusively!
if (!user) {
return <LoginScreen onLogin={handleLogin} defaultConfig={authConfig} />;
}
// 3. FULLY RESPONSIVE DASHBOARD (Only accessible when logged in)
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col w-full">
<Navbar user={user} simrsUrl={authConfig.simrsUrl} onOpenLogin={() => {}} onLogout={handleLogout} />
<main className="flex-1 w-full px-4 md:px-8 py-6">
{actionMessage && (
<div className="mb-5 p-3.5 rounded-xl bg-cyan-500/10 border border-cyan-500/30 text-cyan-300 text-xs flex items-center justify-between">
<div className="flex items-center gap-2">
<Info className="w-4 h-4 text-cyan-400 shrink-0" />
<span>{actionMessage}</span>
</div>
<button onClick={() => setActionMessage('')} className="text-slate-400 hover:text-white font-bold cursor-pointer">
&times;
</button>
</div>
)}
{/* Stats Section */}
<StatsOverview stats={stats} selectedDate={filters.date} />
{/* Brute Force Active Alerts */}
<BruteForcePanel lockedIPs={lockedIPs} onUnblock={handleUnblock} />
{/* Filter Toolbar */}
<FilterToolbar
filters={filters}
backups={backups}
onChange={(updated) => setFilters((prev) => ({ ...prev, ...updated }))}
onReset={() =>
setFilters({
source: 'auto',
date: new Date().toISOString().substring(0, 10),
time_start: '',
time_end: '',
channel: 'all',
level: 'all',
search: '',
limit: 100,
page: 1,
})
}
/>
{/* Main Tabbed Layout */}
<div className="mb-6 w-full">
<div className="flex items-center border-b border-slate-800 mb-4 gap-2">
<button
onClick={() => setActiveTab('logs')}
className={`flex items-center gap-2 px-5 py-3 text-xs font-semibold border-b-2 transition-all cursor-pointer ${
activeTab === 'logs'
? 'border-cyan-400 text-cyan-400 bg-slate-900/60 rounded-t-xl'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<ListFilter className="w-4 h-4" />
Log Entries ({pagination.totalLogs.toLocaleString()} baris)
</button>
<button
onClick={() => setActiveTab('backup')}
className={`flex items-center gap-2 px-5 py-3 text-xs font-semibold border-b-2 transition-all cursor-pointer ${
activeTab === 'backup'
? 'border-amber-400 text-amber-400 bg-slate-900/60 rounded-t-xl'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Archive className="w-4 h-4" />
Arsip Backup ZIP ({backups.length} file)
</button>
</div>
{activeTab === 'logs' ? (
<LogTable
logs={logs}
totalLogs={pagination.totalLogs}
currentPage={pagination.currentPage}
totalPages={pagination.totalPages}
limit={pagination.perPage}
offset={pagination.offset}
loading={loading}
onPageChange={(page) => setFilters((prev) => ({ ...prev, page }))}
onLimitChange={(limit) => setFilters((prev) => ({ ...prev, limit, page: 1 }))}
onLoadMore={() => setFilters((prev) => ({ ...prev, limit: prev.limit + 100 }))}
onExport={handleExport}
/>
) : (
<BackupArchives
backups={backups}
onSelectBackup={(filename) => {
setFilters((prev) => ({ ...prev, source: filename, page: 1 }));
setActiveTab('logs');
}}
onRunRetention={handleRunRetention}
/>
)}
</div>
</main>
</div>
);
}
export default App;
+71
View File
@@ -0,0 +1,71 @@
import React from 'react';
import { Archive, ShieldCheck, Eye, RefreshCw } from 'lucide-react';
import { BackupFile } from '../types';
interface BackupArchivesProps {
backups: BackupFile[];
onSelectBackup: (filename: string) => void;
onRunRetention: () => void;
}
export const BackupArchives: React.FC<BackupArchivesProps> = ({ backups, onSelectBackup, onRunRetention }) => {
return (
<div className="glass-card rounded-xl overflow-hidden mb-6">
<div className="p-4 border-b border-slate-800 flex items-center justify-between bg-slate-900/60">
<div className="text-xs text-slate-400 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-amber-400" />
<span>Berkas log berusia &gt;30 hari diarsipkan secara kompresi per bulan.</span>
</div>
<button
onClick={onRunRetention}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-amber-500/10 hover:bg-amber-500/20 text-amber-400 border border-amber-500/30 text-xs font-semibold transition-colors cursor-pointer"
>
<RefreshCw className="w-3.5 h-3.5" /> Jalankan Retention & Backup Manual
</button>
</div>
{backups.length === 0 ? (
<div className="p-12 text-center text-slate-500 text-xs">
<Archive className="w-10 h-10 mx-auto mb-3 opacity-30 text-slate-400" />
Belum ada file arsip log zip di folder <code className="text-amber-400">logs/backup/</code>. File log berusia &gt;30 hari akan diarsipkan otomatis.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-slate-950/80 text-slate-400 font-semibold border-b border-slate-800">
<tr>
<th className="p-3">Nama Berkas Arsip</th>
<th className="p-3">Ukuran File</th>
<th className="p-3">Waktu Pembuatan</th>
<th className="p-3 text-right">Aksi & Status</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{backups.map((bk, idx) => (
<tr key={idx} className="hover:bg-slate-800/30 transition-colors">
<td className="p-3 font-semibold text-slate-200 flex items-center gap-2">
<Archive className="w-4 h-4 text-amber-400 shrink-0" />
<span>{bk.filename}</span>
</td>
<td className="p-3 font-mono text-slate-400">{bk.size_mb} MB</td>
<td className="p-3 text-slate-400">{bk.mtime}</td>
<td className="p-3 text-right flex items-center justify-end gap-2">
<button
onClick={() => onSelectBackup(bk.filename)}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs font-semibold transition-colors cursor-pointer"
>
<Eye className="w-3.5 h-3.5" /> Baca Log ZIP
</button>
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[11px]">
<ShieldCheck className="w-3 h-3" /> Safe Archive
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
+65
View File
@@ -0,0 +1,65 @@
import React from 'react';
import { ShieldAlert, Unlock } from 'lucide-react';
import { LockedIP } from '../types';
interface BruteForcePanelProps {
lockedIPs: LockedIP[];
onUnblock: (ip: string) => void;
}
export const BruteForcePanel: React.FC<BruteForcePanelProps> = ({ lockedIPs, onUnblock }) => {
if (lockedIPs.length === 0) return null;
const now = Math.floor(Date.now() / 1000);
return (
<div className="glass-card border-rose-500/40 rounded-xl mb-6 overflow-hidden">
<div className="bg-rose-500/10 border-b border-rose-500/20 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-rose-400 font-semibold text-sm">
<ShieldAlert className="w-5 h-5 text-rose-500" />
<span>Peringatan Keamanan: IP Terblokir (Brute Force Lockout Detected)</span>
</div>
<span className="text-xs px-2.5 py-1 rounded-full bg-rose-500/20 text-rose-300 font-bold border border-rose-500/30">
{lockedIPs.length} IP
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-slate-300">
<thead className="bg-slate-900/60 text-slate-400 font-semibold border-b border-slate-800">
<tr>
<th className="p-3">IP Address</th>
<th className="p-3">Percobaan Gagal</th>
<th className="p-3">Sisa Waktu Lockout</th>
<th className="p-3 text-right">Aksi</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{lockedIPs.map((item, idx) => {
const remainSeconds = Math.max(0, item.lockout_until - now);
const remainMinutes = Math.ceil(remainSeconds / 60);
return (
<tr key={idx} className="hover:bg-slate-800/40">
<td className="p-3 font-mono font-bold text-amber-400">{item.ip}</td>
<td className="p-3">{item.attempts?.length || 0}x Percobaan</td>
<td className="p-3">
{remainMinutes} Menit ({new Date(item.lockout_until * 1000).toLocaleTimeString()})
</td>
<td className="p-3 text-right">
<button
onClick={() => onUnblock(item.ip)}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-md bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 font-semibold text-xs transition-all cursor-pointer"
>
<Unlock className="w-3.5 h-3.5" />
Buka Blokir
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};
+158
View File
@@ -0,0 +1,158 @@
import React from 'react';
import { Database, Calendar, Clock, Layers, AlertCircle, Search, Filter, RotateCcw } from 'lucide-react';
import { FilterState, BackupFile } from '../types';
interface FilterToolbarProps {
filters: FilterState;
backups: BackupFile[];
onChange: (updated: Partial<FilterState>) => void;
onReset: () => void;
}
export const FilterToolbar: React.FC<FilterToolbarProps> = ({ filters, backups, onChange, onReset }) => {
return (
<div className="glass-card p-4 rounded-xl mb-6">
<form
onSubmit={(e) => {
e.preventDefault();
onChange({ page: 1 });
}}
className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-7 gap-3 items-end"
>
{/* Sumber Log */}
<div>
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
<Database className="w-3.5 h-3.5 text-cyan-400" /> Sumber Log
</label>
<select
value={filters.source}
onChange={(e) => onChange({ source: e.target.value, page: 1 })}
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
>
<option value="auto"> Auto (Aktif + ZIP)</option>
<option value="active">📁 Log Aktif</option>
<option value="all_zip">📦 Semua Arsip ZIP</option>
{backups.length > 0 && (
<optgroup label="File ZIP:">
{backups.map((b) => (
<option key={b.filename} value={b.filename}>
📦 {b.filename}
</option>
))}
</optgroup>
)}
</select>
</div>
{/* Tanggal */}
<div>
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-cyan-400" /> Tanggal Log
</label>
<input
type="date"
value={filters.date}
onChange={(e) => onChange({ date: e.target.value, page: 1 })}
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
/>
</div>
{/* Rentang Jam */}
<div>
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
<Clock className="w-3.5 h-3.5 text-cyan-400" /> Rentang Jam
</label>
<div className="flex items-center gap-1">
<input
type="time"
value={filters.time_start}
onChange={(e) => onChange({ time_start: e.target.value, page: 1 })}
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-1.5 py-1.5 text-[11px] text-white focus:border-cyan-500 outline-none"
/>
<span className="text-slate-500 text-xs">-</span>
<input
type="time"
value={filters.time_end}
onChange={(e) => onChange({ time_end: e.target.value, page: 1 })}
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-1.5 py-1.5 text-[11px] text-white focus:border-cyan-500 outline-none"
/>
</div>
</div>
{/* Channel */}
<div>
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
<Layers className="w-3.5 h-3.5 text-cyan-400" /> Channel
</label>
<select
value={filters.channel}
onChange={(e) => onChange({ channel: e.target.value, page: 1 })}
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
>
<option value="all">-- Semua Channel --</option>
<option value="session">Session Lifecycle</option>
<option value="auth">Autentikasi & Login</option>
<option value="access">Akses Halaman</option>
<option value="activity">Aktivitas User</option>
<option value="error">Error & Exception</option>
<option value="performance">Performance & Query</option>
<option value="security">Security & BruteForce</option>
<option value="system">System & Retention</option>
</select>
</div>
{/* Level */}
<div>
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
<AlertCircle className="w-3.5 h-3.5 text-cyan-400" /> Level
</label>
<select
value={filters.level}
onChange={(e) => onChange({ level: e.target.value, page: 1 })}
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
>
<option value="all">Semua</option>
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARN</option>
<option value="ERROR">ERR</option>
<option value="CRITICAL">CRIT</option>
</select>
</div>
{/* Kata Kunci */}
<div>
<label className="block text-[11px] font-semibold text-slate-400 mb-1 flex items-center gap-1">
<Search className="w-3.5 h-3.5 text-cyan-400" /> Kata Kunci
</label>
<input
type="text"
placeholder="NIP, URI, IP, Pesan..."
value={filters.search}
onChange={(e) => onChange({ search: e.target.value })}
className="w-full bg-slate-950 border border-slate-800 rounded-lg px-2.5 py-1.5 text-xs text-white focus:border-cyan-500 outline-none"
/>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-1.5">
<button
type="submit"
className="flex-1 py-1.5 px-3 rounded-lg bg-teal-600 hover:bg-teal-500 text-white font-semibold text-xs flex items-center justify-center gap-1 transition-colors cursor-pointer"
>
<Filter className="w-3.5 h-3.5" />
Filter
</button>
<button
type="button"
onClick={onReset}
className="py-1.5 px-2.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-xs transition-colors cursor-pointer"
title="Reset Filter"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
</div>
</form>
</div>
);
};
+320
View File
@@ -0,0 +1,320 @@
import React, { useState } from 'react';
import { Code, Copy, Check, Eye, EyeOff, FileText, Download, ChevronLeft, ChevronRight, PlusCircle, Link as LinkIcon } from 'lucide-react';
import { LogEntry } from '../types';
interface LogTableProps {
logs: LogEntry[];
totalLogs: number;
currentPage: number;
totalPages: number;
limit: number;
offset: number;
loading: boolean;
onPageChange: (page: number) => void;
onLimitChange: (limit: number) => void;
onLoadMore: () => void;
onExport: (type: 'csv' | 'json') => void;
}
export const LogTable: React.FC<LogTableProps> = ({
logs,
totalLogs,
currentPage,
totalPages,
limit,
offset,
loading,
onPageChange,
onLimitChange,
onLoadMore,
onExport,
}) => {
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
const [expandedMsgs, setExpandedMsgs] = useState<Record<number, boolean>>({});
const [copiedRow, setCopiedRow] = useState<number | null>(null);
const toggleRow = (idx: number) => {
setExpandedRows((prev) => ({ ...prev, [idx]: !prev[idx] }));
};
const toggleMsg = (idx: number) => {
setExpandedMsgs((prev) => ({ ...prev, [idx]: !prev[idx] }));
};
const fallbackCopyText = (text: string, idx: number) => {
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.top = '0';
textArea.style.left = '0';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
setCopiedRow(idx);
setTimeout(() => setCopiedRow(null), 2000);
}
} catch (err) {
console.error('Gagal menyalin JSON:', err);
}
};
const handleCopyJson = (log: LogEntry, idx: number) => {
const jsonStr = JSON.stringify(log, null, 2);
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard
.writeText(jsonStr)
.then(() => {
setCopiedRow(idx);
setTimeout(() => setCopiedRow(null), 2000);
})
.catch(() => {
fallbackCopyText(jsonStr, idx);
});
} else {
fallbackCopyText(jsonStr, idx);
}
};
const getLevelBadge = (level: string) => {
const lvl = (level || 'INFO').toUpperCase();
switch (lvl) {
case 'CRITICAL':
return 'bg-rose-600 text-white font-bold';
case 'ERROR':
return 'bg-orange-500 text-white font-bold';
case 'WARNING':
return 'bg-amber-400 text-slate-950 font-bold';
case 'DEBUG':
return 'bg-slate-600 text-white';
default:
return 'bg-cyan-600 text-white';
}
};
return (
<div className="glass-card rounded-xl overflow-hidden mb-6 w-full max-w-full">
{/* Header Bar */}
<div className="p-4 border-b border-slate-800 flex flex-col md:flex-row md:items-center justify-between gap-3 bg-slate-900/60">
<div className="text-xs text-slate-400">
Menampilkan <strong className="text-white">{totalLogs > 0 ? offset + 1 : 0} - {Math.min(offset + logs.length, totalLogs)}</strong> dari{' '}
<strong className="text-white">{totalLogs.toLocaleString()}</strong> baris (Halaman {currentPage} dari {totalPages})
</div>
<div className="flex items-center flex-wrap gap-2">
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<span>Limit:</span>
<select
value={limit}
onChange={(e) => onLimitChange(Number(e.target.value))}
className="bg-slate-950 border border-slate-800 rounded px-2 py-1 text-xs text-white focus:border-cyan-500 outline-none"
>
<option value={50}>50 / hlm</option>
<option value={100}>100 / hlm (Default)</option>
<option value={250}>250 / hlm</option>
<option value={500}>500 / hlm</option>
<option value={1000}>1000 / hlm</option>
</select>
</div>
<button
onClick={() => onExport('csv')}
className="flex items-center gap-1 px-2.5 py-1 rounded bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 text-xs font-semibold transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" /> Export CSV
</button>
<button
onClick={() => onExport('json')}
className="flex items-center gap-1 px-2.5 py-1 rounded bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 text-xs font-semibold transition-colors cursor-pointer"
>
<Download className="w-3.5 h-3.5" /> Export JSON
</button>
</div>
</div>
{/* Table Container */}
<div className="w-full overflow-hidden">
<table className="w-full text-left text-xs text-slate-300 table-fixed">
<thead className="bg-slate-950/80 text-slate-400 font-semibold border-b border-slate-800">
<tr>
<th className="p-3 w-[110px]">Waktu</th>
<th className="p-3 w-[70px]">Level</th>
<th className="p-3 w-[90px]">Channel</th>
<th className="p-3 w-[110px]">IP Address</th>
<th className="p-3 w-[110px]">User NIP</th>
<th className="p-3">Pesan / Event</th>
<th className="p-3 w-[60px] text-center">Detail</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60 font-sans">
{logs.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-12 text-slate-500">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-40" />
Tidak ada log ditemukan untuk kriteria filter ini.
</td>
</tr>
) : (
logs.map((log, idx) => {
const isMsgLong = (log.message || '').length > 130;
const isMsgExpanded = expandedMsgs[idx];
const isRowExpanded = expandedRows[idx];
return (
<React.Fragment key={idx}>
<tr className="hover:bg-slate-800/30 transition-colors align-top">
<td className="p-3 truncate">
<div className="text-[11px] text-slate-500">{log.timestamp ? log.timestamp.substring(0, 10) : '-'}</div>
<div className="font-semibold text-slate-200">{log.timestamp ? log.timestamp.substring(11, 19) : '-'}</div>
</td>
<td className="p-3">
<span className={`px-2 py-0.5 rounded text-[10px] uppercase ${getLevelBadge(log.level)}`}>
{log.level || 'INFO'}
</span>
</td>
<td className="p-3 truncate">
<span className="px-2 py-0.5 rounded bg-slate-800 border border-slate-700 text-cyan-400 font-mono text-[11px] truncate inline-block max-w-full" title={log.channel || '-'}>
{log.channel || '-'}
</span>
</td>
<td className="p-3 font-mono text-slate-300 text-[11px] truncate" title={log.context?.ip_address || '-'}>
{log.context?.ip_address || '-'}
</td>
<td className="p-3 font-medium text-amber-400 text-[11px] truncate" title={log.context?.user_nip || '-'}>
{log.context?.user_nip || '-'}
</td>
<td className="p-3 font-mono text-[12px] leading-relaxed break-all break-words whitespace-pre-wrap overflow-hidden max-w-full">
<div>
{isMsgLong && !isMsgExpanded ? (
<span>
{log.message.substring(0, 130)}...{' '}
<button
onClick={() => toggleMsg(idx)}
className="text-cyan-400 hover:underline text-[11px] inline-flex items-center gap-0.5 ml-1 font-sans cursor-pointer shrink-0"
>
<Eye className="w-3 h-3" /> Detail
</button>
</span>
) : (
<span>
{log.message}
{isMsgLong && (
<button
onClick={() => toggleMsg(idx)}
className="text-slate-400 hover:underline text-[11px] inline-flex items-center gap-0.5 ml-1 font-sans cursor-pointer shrink-0"
>
<EyeOff className="w-3 h-3" /> Ringkas
</button>
)}
</span>
)}
</div>
{log.context?.request_uri && (
<div className="text-[11px] text-slate-500 font-sans mt-1 flex items-center gap-1 truncate max-w-full" title={log.context.request_uri}>
<LinkIcon className="w-3 h-3 text-slate-600 shrink-0" />
<span className="truncate">{log.context.request_uri}</span>
</div>
)}
</td>
<td className="p-3 text-center">
<button
onClick={() => toggleRow(idx)}
className="p-1 rounded border border-slate-700 hover:bg-slate-800 text-cyan-400 transition-colors cursor-pointer"
title="Lihat JSON mentah"
>
<Code className="w-4 h-4" />
</button>
</td>
</tr>
{/* Expandable JSON detail row */}
{isRowExpanded && (
<tr className="bg-slate-950/90">
<td colSpan={7} className="p-4 border-t border-b border-slate-800">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-cyan-400 flex items-center gap-1.5">
<Code className="w-4 h-4" /> Data Log JSON Structure (Row #{offset + idx + 1})
</span>
<button
onClick={() => handleCopyJson(log, idx)}
className="px-2.5 py-1 rounded border border-slate-700 bg-slate-900 hover:bg-slate-800 text-slate-300 text-xs flex items-center gap-1 transition-colors cursor-pointer"
>
{copiedRow === idx ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-emerald-400 font-semibold">Tersalin!</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
<span>Copy JSON</span>
</>
)}
</button>
</div>
<div className="json-box p-3 rounded-lg overflow-x-auto max-h-80 w-full">
<pre className="text-xs text-cyan-300 font-mono whitespace-pre-wrap break-all break-words">
{JSON.stringify(log, null, 2)}
</pre>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination Footer */}
{totalLogs > 0 && (
<div className="p-4 border-t border-slate-800 flex flex-col md:flex-row items-center justify-between gap-3 bg-slate-900/60">
<div className="text-xs text-slate-400">
Halaman <strong className="text-white">{currentPage}</strong> dari <strong className="text-white">{totalPages}</strong>
</div>
<div className="flex items-center gap-2">
<button
disabled={currentPage <= 1 || loading}
onClick={() => onPageChange(currentPage - 1)}
className="p-1.5 rounded bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 transition-all cursor-pointer"
title="Halaman Sebelumnya"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs text-slate-400 px-2">
{currentPage} / {totalPages}
</span>
<button
disabled={currentPage >= totalPages || loading}
onClick={() => onPageChange(currentPage + 1)}
className="p-1.5 rounded bg-slate-800 border border-slate-700 text-slate-300 hover:bg-slate-700 disabled:opacity-40 transition-all cursor-pointer"
title="Halaman Berikutnya"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{currentPage < totalPages && (
<button
onClick={onLoadMore}
disabled={loading}
className="px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-500 text-white text-xs font-semibold flex items-center gap-1.5 transition-colors cursor-pointer"
>
<PlusCircle className="w-4 h-4" /> Muat Log Berikutnya (Lazy Load)
</button>
)}
</div>
)}
</div>
);
};
+186
View File
@@ -0,0 +1,186 @@
import React, { useState } from 'react';
import { Key, User, Lock, Settings, AlertTriangle, Eye, EyeOff, X } from 'lucide-react';
interface LoginModalProps {
isOpen: boolean;
onClose: () => void;
onLogin: (credentials: any) => Promise<void>;
defaultConfig?: any;
}
export const LoginModal: React.FC<LoginModalProps> = ({
isOpen,
onClose,
onLogin,
defaultConfig = {},
}) => {
const [username, setUsername] = useState('[email protected]');
const [password, setPassword] = useState('RSSAjaya2026');
const [showPassword, setShowPassword] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [kcServer, setKcServer] = useState(defaultConfig.defaultKcServer || 'https://auth.rssa.top/');
const [kcRealm, setKcRealm] = useState(defaultConfig.defaultKcRealm || 'rssa');
const [kcClientId, setKcClientId] = useState(defaultConfig.defaultKcClient || 'satu');
const [kcClientSecret, setKcClientSecret] = useState(defaultConfig.defaultKcSecret || 'ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE');
if (!isOpen) return null;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await onLogin({
username,
password,
kc_server: kcServer,
kc_realm: kcRealm,
kc_client_id: kcClientId,
kc_client_secret: kcClientSecret,
});
onClose();
} catch (err: any) {
setError(err.message || 'Login gagal. Periksa username dan password Anda.');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="w-full max-w-md bg-slate-900 border border-slate-800 rounded-2xl p-6 shadow-2xl relative">
<button
onClick={onClose}
className="absolute top-4 right-4 text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800 transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="text-center mb-6">
<div className="w-16 h-16 bg-gradient-to-tr from-teal-500 to-cyan-500 rounded-2xl flex items-center justify-center mx-auto mb-3 shadow-lg shadow-teal-500/30">
<Key className="w-8 h-8 text-white" />
</div>
<h2 className="text-xl font-bold text-white">SIMRS Forensic Inspector</h2>
<p className="text-xs text-slate-400 mt-1">Masuk menggunakan Keycloak SSO / Local Login</p>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-start gap-2">
<AlertTriangle className="w-4 h-4 shrink-0 text-rose-400 mt-0.5" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Username / Email Keycloak</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500">
<User className="w-4 h-4" />
</div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="[email protected]"
required
className="w-full pl-9 pr-3 py-2 rounded-lg bg-slate-950 border border-slate-800 text-white text-sm focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all outline-none"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500">
<Lock className="w-4 h-4" />
</div>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Masukkan Password"
required
className="w-full pl-9 pr-10 py-2 rounded-lg bg-slate-950 border border-slate-800 text-white text-sm focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all outline-none"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-slate-500 hover:text-slate-300"
>
{showPassword ? <EyeOff className="w-4 h-4 text-cyan-400" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Advanced Keycloak Server Accordion */}
<div>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-cyan-400 hover:underline flex items-center gap-1 font-medium cursor-pointer"
>
<Settings className="w-3.5 h-3.5" />
Pengaturan Server Keycloak (Advanced)
</button>
{showAdvanced && (
<div className="mt-3 p-3 rounded-lg bg-slate-950 border border-slate-800 space-y-3 animate-in fade-in duration-150">
<div>
<label className="block text-[11px] text-slate-400 mb-1">Keycloak Server Base URL</label>
<input
type="text"
value={kcServer}
onChange={(e) => setKcServer(e.target.value)}
className="w-full px-2.5 py-1.5 rounded bg-slate-900 border border-slate-800 text-white text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[11px] text-slate-400 mb-1">Realm</label>
<input
type="text"
value={kcRealm}
onChange={(e) => setKcRealm(e.target.value)}
className="w-full px-2.5 py-1.5 rounded bg-slate-900 border border-slate-800 text-white text-xs"
/>
</div>
<div>
<label className="block text-[11px] text-slate-400 mb-1">Client ID</label>
<input
type="text"
value={kcClientId}
onChange={(e) => setKcClientId(e.target.value)}
className="w-full px-2.5 py-1.5 rounded bg-slate-900 border border-slate-800 text-white text-xs"
/>
</div>
</div>
<div>
<label className="block text-[11px] text-slate-400 mb-1">Client Secret</label>
<input
type="password"
value={kcClientSecret}
onChange={(e) => setKcClientSecret(e.target.value)}
className="w-full px-2.5 py-1.5 rounded bg-slate-900 border border-slate-800 text-white text-xs"
/>
</div>
</div>
)}
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 rounded-lg bg-teal-600 hover:bg-teal-500 text-white font-semibold text-sm shadow-lg shadow-teal-600/30 transition-all flex items-center justify-center gap-2 disabled:opacity-50 cursor-pointer"
>
{loading ? 'Memproses...' : 'Masuk (Keycloak SSO / Local)'}
</button>
</form>
</div>
</div>
);
};
+266
View File
@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import { Key, User, Lock, Settings, AlertTriangle, Eye, EyeOff, Keyboard, ArrowLeft, ExternalLink, ShieldCheck } from 'lucide-react';
interface LoginScreenProps {
onLogin: (credentials: any) => Promise<void>;
defaultConfig?: any;
}
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin, defaultConfig = {} }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const isKcEnvConfigured = defaultConfig?.isKcEnvConfigured ?? false;
const [kcServer, setKcServer] = useState(defaultConfig.defaultKcServer || 'https://auth.rssa.top/');
const [kcRealm, setKcRealm] = useState(defaultConfig.defaultKcRealm || 'rssa');
const [kcClientId, setKcClientId] = useState(defaultConfig.defaultKcClient || 'satu');
const [kcClientSecret, setKcClientSecret] = useState(defaultConfig.defaultKcSecret || 'ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE');
const [kcRedirectUri, setKcRedirectUri] = useState('');
// Check URL query error
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const errParam = params.get('error');
if (errParam) {
setError(decodeURIComponent(errParam));
}
}, []);
const handlePkceLogin = () => {
const params = new URLSearchParams({
kc_server: kcServer,
kc_realm: kcRealm,
kc_client_id: kcClientId,
kc_client_secret: kcClientSecret,
});
if (kcRedirectUri) {
params.append('kc_redirect_uri', kcRedirectUri);
}
window.location.href = `/api/auth/pkce/login?${params.toString()}`;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
await onLogin({
username,
password,
kc_server: kcServer,
kc_realm: kcRealm,
kc_client_id: kcClientId,
kc_client_secret: kcClientSecret,
});
} catch (err: any) {
setError(err.message || 'Login gagal. Periksa username dan password.');
} finally {
setLoading(false);
}
};
const simrsUrl = defaultConfig?.simrsUrl || '/';
return (
<div className="min-h-screen bg-slate-950 text-slate-100 flex items-center justify-center p-4 relative overflow-hidden">
{/* Ambient Glows */}
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-teal-500/10 rounded-full blur-[140px] pointer-events-none" />
<div className="absolute bottom-10 right-10 w-[400px] h-[400px] bg-cyan-500/10 rounded-full blur-[120px] pointer-events-none" />
<div className="w-full max-w-md bg-slate-900/90 backdrop-blur-xl border border-slate-800 rounded-3xl p-8 shadow-2xl shadow-slate-950 relative z-10">
{/* Brand Icon */}
<div className="w-20 h-20 bg-gradient-to-tr from-teal-500 to-cyan-500 rounded-2xl flex items-center justify-center mx-auto mb-4 shadow-xl shadow-teal-500/30">
<Key className="w-10 h-10 text-white" />
</div>
<div className="text-center mb-6">
<h2 className="text-2xl font-bold tracking-tight text-white">SIMRS Logs Inspector</h2>
<p className="text-xs text-slate-400 mt-1.5 flex items-center justify-center gap-1">
<ShieldCheck className="w-3.5 h-3.5 text-teal-400" />
Keamanan SSO Terproteksi Standar
</p>
</div>
{error && (
<div className="mb-5 p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 shrink-0 text-rose-400 mt-0.5" />
<span>{error}</span>
</div>
)}
{/* Primary PKCE Login Action */}
<div className="mb-6">
<button
type="button"
onClick={handlePkceLogin}
className="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-teal-500 via-cyan-500 to-blue-600 hover:from-teal-400 hover:to-cyan-400 text-white font-bold text-sm shadow-xl shadow-teal-500/25 transition-all flex items-center justify-center gap-2.5 cursor-pointer group"
>
<ShieldCheck className="w-5 h-5 text-cyan-200 group-hover:scale-110 transition-transform" />
<span>Masuk via Keycloak SSO</span>
<ExternalLink className="w-4 h-4 text-cyan-200" />
</button>
<p className="text-[11px] text-slate-500 text-center mt-2">
Direkomendasikan. Pengalihan aman ke server identitas Keycloak.
</p>
</div>
<div className="relative flex py-2 items-center mb-6">
<div className="flex-grow border-t border-slate-800"></div>
<span className="flex-shrink mx-4 text-[11px] text-slate-500 font-semibold uppercase">Atau Akun Lokal / Pengujian</span>
<div className="flex-grow border-t border-slate-800"></div>
</div>
{/* Local / Direct Fallback Login Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Username / Email (Local / Dev)
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
<User className="w-4 h-4" />
</div>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username / Email (cth: [email protected])"
className="w-full pl-10 pr-3.5 py-2 rounded-xl bg-slate-950/80 border border-slate-800 text-white text-sm focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all outline-none"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Password</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-500">
<Lock className="w-4 h-4" />
</div>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Masukkan Password"
className="w-full pl-10 pr-10 py-2 rounded-xl bg-slate-950/80 border border-slate-800 text-white text-sm focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500 transition-all outline-none"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-slate-500 hover:text-slate-300 transition-colors"
title="Tampilkan / Sembunyikan Password"
>
{showPassword ? <EyeOff className="w-4 h-4 text-cyan-400" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Advanced Keycloak Server Config (Ditampilkan HANYA jika .env Keycloak belum terkonfigurasi) */}
{!isKcEnvConfigured && (
<div>
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="text-xs text-cyan-400 hover:underline flex items-center gap-1 font-medium cursor-pointer"
>
<Settings className="w-3.5 h-3.5" />
Pengaturan Server Keycloak (Advanced)
</button>
{showAdvanced && (
<div className="mt-3 p-3.5 rounded-xl bg-slate-950/90 border border-slate-800 space-y-3 animate-in fade-in duration-150">
<div>
<label className="block text-[11px] text-slate-400 mb-1">Keycloak Server Base URL</label>
<input
type="text"
value={kcServer}
onChange={(e) => setKcServer(e.target.value)}
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-[11px] text-slate-400 mb-1">Realm</label>
<input
type="text"
value={kcRealm}
onChange={(e) => setKcRealm(e.target.value)}
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs"
/>
</div>
<div>
<label className="block text-[11px] text-slate-400 mb-1">Client ID</label>
<input
type="text"
value={kcClientId}
onChange={(e) => setKcClientId(e.target.value)}
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs"
/>
</div>
</div>
<div>
<label className="block text-[11px] text-slate-400 mb-1">Client Secret</label>
<input
type="password"
value={kcClientSecret}
onChange={(e) => setKcClientSecret(e.target.value)}
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs"
/>
</div>
<div>
<label className="block text-[11px] text-slate-400 mb-1">Custom Redirect URI (Opsional)</label>
<input
type="text"
value={kcRedirectUri}
onChange={(e) => setKcRedirectUri(e.target.value)}
placeholder="http://meninjar.dev.rssa.id:5880/api/auth/pkce/callback"
className="w-full px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-white text-xs font-mono"
/>
</div>
</div>
)}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700 font-semibold text-xs transition-all flex items-center justify-center gap-2 disabled:opacity-50 cursor-pointer"
>
{loading ? 'Memproses...' : 'Masuk Akun Lokal / Pengujian'}
</button>
</form>
{/* Secret Hotkey Hint */}
{/* <div className="mt-6 text-center">
<div
onClick={() =>
alert(
'🔑 Secret Key Shortcuts:\n\n1. [Ctrl + Alt + F] atau [Ctrl + Shift + F] : Langsung menuju Forensic Inspector\n2. [Ctrl + Shift + K] : Fokus cepat ke kolom Username SSO\n3. Ketik kata "forensic" atau "inspector" kapan saja.'
)
}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-950 border border-slate-800 text-cyan-400 text-xs font-mono cursor-pointer hover:border-cyan-500/50 transition-colors"
>
<Keyboard className="w-3.5 h-3.5" />
<span>Shortcut: <kbd className="bg-slate-800 text-white px-1.5 py-0.5 rounded">Ctrl+Alt+F</kbd> / <code>forensic</code></span>
</div>
</div> */}
<div className="mt-4 text-center">
<a
href={simrsUrl}
className="text-xs text-slate-500 hover:text-slate-300 inline-flex items-center gap-1 transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Kembali ke SIMRS Utama
</a>
</div>
</div>
</div>
);
};
+71
View File
@@ -0,0 +1,71 @@
import React from 'react';
import { Search, UserCheck, Power, Key, Home } from 'lucide-react';
import { UserSession } from '../types';
interface NavbarProps {
user: UserSession | null;
simrsUrl?: string;
onOpenLogin: () => void;
onLogout: () => void;
}
export const Navbar: React.FC<NavbarProps> = ({ user, simrsUrl = '/', onLogout }) => {
const handlePkceLogin = () => {
window.location.href = '/api/auth/pkce/login';
};
return (
<header className="bg-slate-900/90 backdrop-blur-md border-b border-slate-800 sticky top-0 z-40 px-4 md:px-8 py-3 shadow-lg w-full">
<div className="w-full flex items-center justify-between">
{/* Brand */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-teal-500 to-cyan-500 flex items-center justify-center shadow-lg shadow-teal-500/20">
<Search className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-lg font-bold tracking-tight text-white flex items-center gap-2">
SIMRS Logs Inspector
</h1>
<p className="text-xs text-slate-400">System Diagnostics, Audit & Security Monitor</p>
</div>
</div>
{/* Right Actions */}
<div className="flex items-center gap-3">
{user ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-800/80 border border-slate-700 text-xs font-medium text-cyan-300">
<UserCheck className="w-4 h-4 text-cyan-400" />
<span>SSO ({user.auth_type?.includes('pkce') ? 'PKCE' : 'Local'}): {user.name || user.username}</span>
</div>
<button
onClick={onLogout}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-300 hover:bg-rose-500/20 text-xs font-semibold transition-colors cursor-pointer"
title="Keluar Sesi Keycloak SSO"
>
<Power className="w-3.5 h-3.5" />
Logout
</button>
</div>
) : (
<button
onClick={handlePkceLogin}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-teal-600 hover:bg-teal-500 text-white text-xs font-semibold shadow-md shadow-teal-600/30 transition-all cursor-pointer"
>
<Key className="w-3.5 h-3.5" />
Login SSO Keycloak (PKCE)
</button>
)}
<a
href={simrsUrl}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-300 border border-slate-700 text-xs font-medium transition-colors"
>
<Home className="w-3.5 h-3.5" />
Ke SIMRS
</a>
</div>
</div>
</header>
);
};
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import { Activity, CheckCircle, Clock, AlertOctagon, Zap, ShieldAlert } from 'lucide-react';
import { LogStats } from '../types';
interface StatsOverviewProps {
stats: LogStats;
selectedDate: string;
}
export const StatsOverview: React.FC<StatsOverviewProps> = ({ stats, selectedDate }) => {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 mb-6">
{/* Total Event */}
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
<span>Total Event</span>
<Activity className="w-4 h-4 text-cyan-400" />
</div>
<div className="mt-2">
<div className="text-2xl font-bold text-white">{stats.total.toLocaleString()}</div>
<div className="text-[11px] text-cyan-400 mt-0.5 truncate">{selectedDate}</div>
</div>
</div>
{/* Login Sukses */}
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
<span>Login Sukses</span>
<CheckCircle className="w-4 h-4 text-emerald-400" />
</div>
<div className="mt-2">
<div className="text-2xl font-bold text-emerald-400">{stats.login_success.toLocaleString()}</div>
<div className="text-[11px] text-slate-400 mt-0.5">
Gagal: <span className="text-rose-400 font-semibold">{stats.login_failed}</span>
</div>
</div>
</div>
{/* Session Expired */}
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
<span>Session Expired</span>
<Clock className="w-4 h-4 text-amber-400" />
</div>
<div className="mt-2">
<div className="text-2xl font-bold text-amber-400">{stats.session_expired.toLocaleString()}</div>
<div className="text-[11px] text-slate-400 mt-0.5">Navigasi Terhalang</div>
</div>
</div>
{/* Errors & Critical */}
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
<span>Errors / Crit</span>
<AlertOctagon className="w-4 h-4 text-rose-400" />
</div>
<div className="mt-2">
<div className="text-2xl font-bold text-rose-400">{stats.errors.toLocaleString()}</div>
<div className="text-[11px] text-slate-400 mt-0.5">
Warning: <span className="text-amber-400 font-semibold">{stats.warnings}</span>
</div>
</div>
</div>
{/* Avg Response Time */}
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
<span>Avg Response</span>
<Zap className="w-4 h-4 text-cyan-400" />
</div>
<div className="mt-2">
<div className="text-2xl font-bold text-cyan-400">
{stats.avg_response_ms} <span className="text-xs font-normal text-slate-400">ms</span>
</div>
<div className="text-[11px] text-slate-400 mt-0.5">{stats.performance_count} Request</div>
</div>
</div>
{/* Brute Force Locked */}
<div className="glass-card glass-card-hover p-4 rounded-xl flex flex-col justify-between">
<div className="flex items-center justify-between text-slate-400 text-xs font-semibold uppercase tracking-wider">
<span>IP Locked</span>
<ShieldAlert className="w-4 h-4 text-rose-400" />
</div>
<div className="mt-2">
<div className="text-2xl font-bold text-rose-500">{stats.brute_force_locked}</div>
<div className="text-[11px] text-slate-400 mt-0.5">Brute Force Detected</div>
</div>
</div>
</div>
);
};
+51
View File
@@ -0,0 +1,51 @@
@import "tailwindcss";
@layer base {
html, body, #root {
background-color: #090d16;
color: #e2e8f0;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
overflow-x: hidden;
max-width: 100vw;
}
}
.glass-card {
background: rgba(30, 41, 59, 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(51, 65, 85, 0.6);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
}
.glass-card-hover {
transition: all 0.2s ease-in-out;
}
.glass-card-hover:hover {
transform: translateY(-2px);
border-color: rgba(56, 189, 248, 0.4);
box-shadow: 0 15px 30px -5px rgba(14, 165, 233, 0.15);
}
.json-box {
background: #050811;
border: 1px solid #1e293b;
color: #38bdf8;
font-family: 'Fira Code', monospace;
font-size: 0.82rem;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #090d16;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+64
View File
@@ -0,0 +1,64 @@
export interface LogContext {
ip_address?: string;
user_nip?: string;
request_uri?: string;
total_duration_ms?: number;
raw_log?: string;
[key: string]: any;
}
export interface LogEntry {
timestamp: string;
channel: string;
level: string;
message: string;
correlation_id: string;
context: LogContext;
_seq?: number;
}
export interface LogStats {
total: number;
login_success: number;
login_failed: number;
session_expired: number;
errors: number;
warnings: number;
performance_count: number;
avg_response_ms: number;
brute_force_locked: number;
}
export interface LockedIP {
ip: string;
attempts: any[];
lockout_until: number;
}
export interface BackupFile {
filename: string;
size_mb: number;
mtime: string;
}
export interface UserSession {
username: string;
name: string;
email?: string;
login_time: string;
auth_type: string;
kc_server?: string;
kc_realm?: string;
}
export interface FilterState {
source: string;
date: string;
time_start: string;
time_end: string;
channel: string;
level: string;
search: string;
limit: number;
page: number;
}